Las cuatro capas, en dos minutos
La api está partida en cuatro capas. Lo único que hay que interiorizar es que el núcleo no mira
hacia afuera: domain/ no depende de nadie y application/ solo depende de domain/. Las dos
capas de fuera —presentation/ e infrastructure/— sí se ven entre ellas, y eso es normal.
presentation → application → domain
↓ ↑
infrastructure ────────────────────┘
(implementa lo que domain y application declaran)
| Capa | Qué guarda | Qué NO puede hacer |
|---|---|---|
domain/ |
Entidades, interfaces de repositorio, errores de negocio | Importar nada: ni NestJS, ni Prisma, ni librerías |
application/ |
Use cases, ports, eventos, handlers | Importar de infrastructure/ o de presentation/ |
infrastructure/ |
Prisma, MinIO, Redis, argon2, pdfmake, HTTP saliente | Contener reglas de negocio |
presentation/ |
Controllers, DTOs, guards, módulos de Nest | Pedir datos de negocio sin pasar por un use case |
Ojo con esa flecha de presentation a infrastructure: apunta hacia abajo porque sí existe, y
conviene saber para qué, que si no parece una grieta. Son 5 ficheros de 169, y de dos tipos:
- Configuración (4):
auth-cookies.tsy los controllers dediagnosisyresources, más el guard dewebhooks, inyectanENV_CONFIGpara construir una cookie o una URL firmada. Necesitan un TTL o una URL base, no datos. - El health check (1):
health.controller.tsinyectaPrismaServicey haceSELECT 1. Es lo que un healthcheck es; un use case por medio solo añadiría indirección.
Lo que no ocurre nunca es que un controller pida datos de negocio saltándose la aplicación.
Las dos reglas que no se rompen
Estas dos no son aspiraciones, se cumplen hoy en todo el repo y conviene comprobarlas antes de dar por buena una feature:
- Ningún controller inyecta un repositorio. Si un controller necesita datos, pasa por un use case. Cero excepciones.
- Nada de
application/importa deinfrastructure/ni depresentation/. Un use case no sabe que existe Prisma, ni que existe HTTP. Cero excepciones.
Son fáciles de verificar y sirven de test de humo al revisar un cambio:
grep -rl "REPOSITORY" src/presentation/ # debe salir vacío
grep -rl "@/infrastructure\|@/presentation" src/application/ # debe salir vacío
¿Cómo se invierte la dependencia?
domain/ declara qué necesita, infrastructure/ implementa cómo, y Nest los une por un
Symbol. Ese símbolo vive junto a la interfaz, nunca junto a la implementación:
// domain/pet-tags/pet-tag.repository.ts
export interface PetTagRepository {
findByPublicCode(publicCode: string): Promise<PetTag | null>;
create(tag: PetTag): Promise<PetTag>;
}
export const PET_TAG_REPOSITORY = Symbol("PetTagRepository");
// application/pet-tags/create-pet-tag.use-case.ts
constructor(
@Inject(PET_TAG_REPOSITORY) private readonly petTags: PetTagRepository,
) {}
Fíjate en el detalle del import en el use case, porque es obligatorio y da un error raro si se
olvida: la interfaz se importa con import type y el símbolo con un import normal.
import type { PetTagRepository } from "@/domain/pet-tags/pet-tag.repository";
import { PET_TAG_REPOSITORY } from "@/domain/pet-tags/pet-tag.repository";
Si importas la interfaz sin type, TypeScript lanza TS1272: con isolatedModules no puede saber
que ese nombre desaparece al compilar, y el decorador @Inject intentaría emitir metadata sobre
algo que no existe en tiempo de ejecución.
Por dónde sigue
- Si vas a construir algo nuevo, elige recorrido: con entidad propia o sin entidad propia.
- Si ya estás dentro y tienes una duda concreta, salta a la decisión: repositorio o port, use case o service.