Architecture

Clean Architecture

Clean Architecture es una regla con grandes consecuencias: las dependencias apuntan hacia adentro. El dominio no sabe nada de la base de datos, el framework o HTTP, por lo que las reglas de negocio pueden probarse y cambiarse sin arrastrar la infraestructura.

intermediate14 min readUpdated 16 sept 2026
application/place-order.ts
ts
// application/place-order.ts
import { Order } from "../domain/order.js";
import type { OrderRepository } from "../domain/order-repository.js";

export class PlaceOrder {
  // The domain owns the interface; infrastructure provides it.
  constructor(private readonly orders: OrderRepository) {}

  async execute(input: PlaceOrderInput): Promise<PlaceOrderResult> {
    const order = Order.place(input);

    await this.orders.save(order);

    return { orderId: order.id, totalCents: order.total.cents };
  }
}
Popularizado por
Robert C. Martin
También llamado
Hexagonal / ports and adapters
Regla central
Las dependencias apuntan hacia adentro
El dominio importa
Nada del exterior
Testeabilidad
Dominio sin base de datos
Ideal para
Reglas que sobreviven a los frameworks

Por que importa

Qué protege esta disciplina

Las reglas de negocio sobreviven a las herramientas

El dominio se escribe en términos del negocio, no del framework. Cuando la librería de HTTP, el ORM o la base de datos cambian, las reglas que definen qué es un pedido no se mueven.

El dominio se prueba en milisegundos

Como el dominio no depende de nada, sus tests no necesitan servidor, base de datos ni red. Los casos de uso se ejecutan contra fakes en memoria y terminan antes de que se pueda abrir la primera conexión real.

Los adaptadores son detalles reemplazables

Postgres, Stripe y Express son implementaciones de interfaces que el dominio define. Cambiar uno por un fake, un stub o un proveedor diferente es crear un nuevo adaptador, no reescribir el sistema.

La imagen completa

Tres capas, una regla

Las capas externas pueden importar las internas. Las capas internas no saben nada de las externas. Cada beneficio de este estilo se deriva de esa única dirección.

Dominio

Modelar

Entidades y objetos de valor que codifican las reglas e invariantes del negocio, expresados en su propio lenguaje sin conocimiento de cómo se almacenan o se entregan.

Caso de uso

Orquestar

Servicios de aplicación que coordinan el dominio para cumplir una intención, llaman a los puertos que necesitan y devuelven datos simples. Contienen el flujo, no las reglas.

Adaptador

Implementar

Clases de la capa externa que implementan los puertos contra una tecnología real —una base de datos, un cliente HTTP, un message broker— y traducen entre sus tipos y los del dominio.

HTML5 de un vistazo

Las cuatro capas y sus partes

Entidades

Objetos de negocio centrales e invariantes que existirían incluso sin software.

Casos de uso

Servicios de aplicación que orquestan entidades para completar una tarea.

Puertos

Interfaces que el dominio posee y que las capas externas implementan.

Adaptadores

Postgres, HTTP, Stripe y otras implementaciones concretas de puertos.

Composition root

El único lugar que conecta los adaptadores reales con los casos de uso al iniciar la aplicación.

Tests

Unit tests con fakes para el dominio; integration tests para los adaptadores.

Flujo

El flujo de una petición a través de las capas

El dominio en el centro no importó nada del exterior. Cada dependencia cruzó una interfaz que el dominio posee, y cada traducción ocurrió en el borde.

  1. 1

    El controlador analiza la entrada

    Un handler HTTP de la capa externa lee la petición, valida la estructura y la convierte en datos simples. Los tipos del framework terminan aquí.

  2. 2

    Llama a un caso de uso

    El controlador invoca el caso de uso con un objeto de entrada simple y recibe un resultado simple. No sabe cómo se realiza el trabajo.

  3. 3

    El caso de uso orquestra el dominio

    El caso de uso carga o crea entidades, llama a sus métodos para hacer cumplir los invariantes y decide el orden de las operaciones.

  4. 4

    El caso de uso llama a un puerto

    Cuando necesita persistencia o un servicio externo, llama a una interfaz como OrderRepository que el dominio define.

  5. 5

    Un adaptador implementa el puerto

    Un repositorio de Postgres, inyectado al inicio, realiza la consulta real y mapea las filas de vuelta a objetos de dominio.

  6. 6

    El resultado retorna hacia afuera

    Los datos del dominio fluyen de vuelta al controlador, que los mapea a una respuesta. Nada en el dominio sabe que existe una respuesta.

La guia completa

Clean Architecture: Todo lo que necesitas saber

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, un PaymentGateway, un Clock. 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 any a 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.

En la practica

Entidad, caso de uso, adaptador, conexión

La misma pequeña porción de un sistema de pedidos, vista desde cada capa.

domain/order.ts
import { Money } from "./money.js";

export type OrderLine = {
  sku: string;
  quantity: number;
  unitPrice: Money;
};

export type OrderStatus = "placed" | "cancelled";

export class Order {
  private constructor(
    readonly id: string,
    readonly customerId: string,
    readonly lines: OrderLine[],
    private status: OrderStatus,
  ) {}

  static place(input: { customerId: string; lines: OrderLine[] }): Order {
    if (input.lines.length === 0) {
      throw new Error("an order needs at least one line");
    }

    return new Order(
      crypto.randomUUID(),
      input.customerId,
      input.lines,
      "placed",
    );
  }

  // Rebuild an entity from persisted state without re-running rules.
  static reconstitute(input: {
    id: string;
    customerId: string;
    lines: OrderLine[];
    status: OrderStatus;
  }): Order {
    return new Order(input.id, input.customerId, input.lines, input.status);
  }

  get total(): Money {
    return this.lines.reduce(
      (sum, line) => sum.add(line.unitPrice.multiply(line.quantity)),
      Money.zero(),
    );
  }

  cancel(): void {
    if (this.status === "cancelled") return;
    this.status = "cancelled";
  }
}

Dependencias hacia adentro vs el dominio importando el ORM

En el momento en que una entidad importa un framework, las reglas dependen de una herramienta. Ese acoplamiento es invisible hasta que intentas probar o reemplazar la herramienta.

Hacia adentro
// domain/order.ts
import { Money } from "./money.js";

// The domain imports only the domain.
export class Order {
  get total(): Money {
    return this.lines.reduce(
      (sum, line) => sum.add(line.unitPrice.multiply(line.quantity)),
      Money.zero(),
    );
  }
}
Acoplado
// domain/order.ts
import { Entity, Column } from "typeorm";

// The domain now depends on a persistence tool.
@Entity()
export class Order {
  @Column() customerId!: string;

  // Rules and schema concerns are tangled together.
}

Un puerto en el caso de uso vs una llamada directa a la base de datos

Llamar al pool desde el caso de uso parece eficiente, pero acopla silenciosamente la aplicación a Postgres. Un puerto mantiene el flujo y la tecnología separados.

Puerto
// application/place-order.ts
export class PlaceOrder {
  constructor(private readonly orders: OrderRepository) {}

  async execute(input: PlaceOrderInput) {
    const order = Order.place(input);

    await this.orders.save(order); // an interface, not a driver
    return { orderId: order.id };
  }
}
Directo
// application/place-order.ts
import { pool } from "../infrastructure/db.js";

export async function placeOrder(input: PlaceOrderInput) {
  const order = Order.place(input);

  // The use case now knows SQL, Postgres and the table schema.
  await pool.query(
    "INSERT INTO orders (id, customer_id) VALUES ($1, $2)",
    [order.id, order.customerId],
  );
}

Compromisos

Costos y beneficios de Clean Architecture

El patrón no es gratis. Compra testeabilidad e independencia a cambio de más archivos, interfaces y mapeos. Si es un buen intercambio depende totalmente de cuánta lógica de negocio tengas.

Strengths

  • El dominio es rápido de probar

    Los tests de casos de uso se ejecutan en milisegundos contra fakes en memoria, por lo que las reglas se cubren sin base de datos, contenedores ni red. Esa velocidad cambia la frecuencia con la que se escriben y ejecutan los tests.

  • Los frameworks se vuelven detalles

    Express, Postgres y Stripe se sitúan en el borde detrás de interfaces. Actualizar o reemplazar uno es un cambio de adaptador, y una reescritura del mecanismo de entrega no toca las reglas.

  • Los límites hacen visible la intención

    Una interfaz de repositorio dice qué necesita la aplicación del almacenamiento. Un caso de uso dice qué hace el sistema. Los nombres por sí solos explican el diseño a un nuevo lector.

Trade-offs

  • Más archivos, más indirección

    Cada operación gana un caso de uso, un puerto y un mapper. Para un endpoint CRUD simple, esto significa varios archivos haciendo lo que hacía un solo handler, y el rastro desde la petición hasta la consulta se vuelve más largo.

  • El mapeo es trabajo real

    Las filas se convierten en objetos de dominio y los objetos de dominio en respuestas. El código de mapeo es aburrido, fácil de implementar mal y necesita sus propios tests, pero omitirlo filtra los tipos de una capa en otra.

  • Fácil de sobre-aplicar

    Abstraer cada tabla y cada llamada produce ceremonia sin recompensa. El estilo rinde donde las reglas no son triviales; aplicado a una pantalla de configuración es pura sobrecarga.

Preguntas frecuentes

Preguntas frecuentes

Keep learning

Related topics from the roadmap.

$ comienza a aprender

Listo para aprender Clean Architecture?

Nuestro tutorial interactivo te guia a traves de Clean Architecture paso a paso — con quizzes y codigo real que puedes ejecutar en el navegador.