Imported from AndresGacharna/andres-nest-agent-skills (
skills/andres-nestjs-architecture/SKILL.md). Install upstream withnpx skills add AndresGacharna/andres-nest-agent-skills --skill andres-nestjs-architecture. Copyright stays with the author.
NestJS Architecture
Convenciones propias para que todas las APIs se lean igual y escalen sin reescribir la base.
Construidas tomando como referencia convenciones de equipo y la skill de terceros
nestjs-best-practices(kadajett/agent-nestjs-skills). Las decisiones y el contenido de esta skill son propios.
Principios
- Controladores delgados. Reciben la petición, validan con DTOs y delegan. Sin
try/catch, sin formatear respuestas y sin logs manuales. - Los servicios lanzan, no retornan errores. Nunca
return new XException()ni{ success: false }. - Lo transversal va una sola vez, en
common/. Mensajes de éxito, logging, errores y serialización se registran globalmente enCommonModule. - Cada módulo es dueño de lo suyo. Sus errores, mensajes, constantes, DTOs y entidades viven dentro de su carpeta.
common/nunca importa de un módulo de negocio ni guarda textos o datos de un módulo. - Nada de
process.envfuera desrc/config/. Todo se lee conConfigService<EnvironmentVariables, true>.
Estructura
src/
├── main.ts bootstrap: prefijo, ValidationPipe, CORS, puerto
├── app.module.ts ConfigModule, TypeORM, CommonModule y módulos de negocio
├── config/
│ ├── env.validation.ts EnvironmentVariables + validateEnv (falla al arrancar)
│ └── database.config.ts buildTypeOrmOptions(config)
├── common/ solo lo transversal, sin lógica ni textos de negocio
│ ├── common.module.ts APP_FILTER + APP_INTERCEPTOR
│ ├── constants/ common.errors.ts, postgres-error-codes.ts
│ ├── decorators/ @ResponseMessage()
│ ├── exceptions/ DomainException
│ ├── filters/ AllExceptionsFilter
│ ├── interceptors/ LoggingInterceptor, ResponseMessageInterceptor
│ └── interfaces/ MessageResponse, ApiErrorResponse, ErrorDefinition
└── <feature>/ un módulo por dominio (company, supplier, ...)
├── <feature>.module.ts
├── constants/<feature>.errors.ts
├── constants/<feature>.messages.ts
├── controllers/<feature>.controller.ts
├── dto/create-<feature>.dto.ts, update-<feature>.dto.ts
├── entities/<feature>.entity.ts
├── interfaces/ opcional: tipos internos que no cruzan HTTP
└── services/<feature>.service.ts
Plantilla completa de un módulo: references/module-template.md.
Reglas
ESM
- Los imports relativos terminan en
.js:import { X } from './x.service.js'. - Las relaciones de TypeORM se tipan con
Relation<T>. - Orden de imports: paquetes externos, línea en blanco, imports relativos.
- Tipos que solo se usan como tipo:
import type { X }. Se borran al compilar y no crean dependencias entre archivos. - ❌ Barrels (
index.tsque reexporta una carpeta): cada import apunta al archivo. Un barrel arrastra todo lo de la carpeta y esconde ciclos entre módulos, que en ESM terminan enCannot access 'X' before initialization.
Controladores
- ✅
return this.service.metodo(...). El controlador no arma la respuesta. - ✅ Consultas (
GET, oPOSTcon paginación o filtros en el body): sin@ResponseMessage. Devuelven el resultado tal cual. - ✅ Consultas por
POST: agregar@HttpCode(HttpStatus.OK). Sin él, Nest responde201 Createden todoPOST. Nombre de ruta descriptivo:POST /companies/search. - ✅ Acciones (crear, actualizar, eliminar): siempre
@ResponseMessage(<Feature>Messages.<ACCION>). Responden{ message, data? }. - ✅
DELETEresponde200(default de Nest) para que el mensaje llegue. Nunca@HttpCode(HttpStatus.NO_CONTENT)en una acción con mensaje: un204no tiene body. - ✅
ParseUUIDPipeen los:id, DTOs conclass-validatoren@Body()y@Query(). - ❌
extends ControllerBase,manageResponse,try/catcho@Res()(se salta interceptores y filtros). - ❌
@UseInterceptors(ClassSerializerInterceptor)onew Logger()por controlador: ya son globales. - ❌ Rutas que repiten el verbo HTTP en acciones (
POST /create,PUT /update/:id): usarPOST /companies,PATCH /companies/:id.
Servicios
- ✅ Devolver el recurso creado o actualizado (o
voidsi no hay nada que devolver). El mensaje de éxito lo agrega el controlador con@ResponseMessage, nunca el servicio. - ✅ Recurso inexistente:
throw new DomainException(<Feature>Errors.NOT_FOUND). - ✅ Regla de negocio violada:
throw new DomainException(<Feature>Errors.<CASO>). - ✅ Un
try/catchsolo si se va a recuperar algo (reintento, fallback, procesar un lote parcial). Si solo se va a relanzar, no se escribe. - ✅
returnpara resultados esperados aunque no sean el caso feliz (null,boolean, resultados parciales);throwsolo cuando la petición no puede continuar. - ❌
return '<Recurso> creado exitosamente'(o una claseServiceResponse): el mensaje va en el controlador. - ❌
throw new BadRequestException(error.message)con el mensaje de un error desconocido: filtra detalles internos. - ❌ Comparar
error.message === '...'para decidir: usarerror instanceof DomainException && error.code === X.code.
Errores
- Catálogo por módulo en
<feature>/constants/<feature>.errors.ts:export const SupplierErrors = { NOT_FOUND: { code: 'SUPPLIER_NOT_FOUND', message: 'Supplier not found', status: HttpStatus.NOT_FOUND, }, } as const satisfies Record<string, ErrorDefinition>; message: texto personalizado que el frontend muestra.code:SCREAMING_SNAKE_CASEcon el prefijo del módulo (SUPPLIER_,COST_CENTER_) para que sea único en toda la API. Cuando el frontend o el back necesiten decidir algo según el error, se usacode, nuncamessage.- Mensaje dinámico:
new DomainException(SupplierErrors.NOT_FOUND, \Supplier ${id} not found`). Elcode` y el status no cambian. common/constants/common.errors.tses solo para errores transversales. No agregar errores de negocio ahí.- Duplicados (23505) y claves foráneas (23503) de Postgres ya se traducen a 409 en el filtro. Validar antes en el servicio solo si se necesita un
codeespecífico del módulo.
Mensajes de éxito
- Catálogo por módulo en
<feature>/constants/<feature>.messages.ts:export const SupplierMessages = { CREATED: 'Supplier created successfully', UPDATED: 'Supplier updated successfully', DELETED: 'Supplier deleted successfully', } as const; - Toda acción (crear, actualizar, eliminar, procesos que modifican datos) lleva mensaje. Las consultas no.
- Si el texto depende del resultado (por ejemplo "3 creados, 2 omitidos"), no es un mensaje fijo: el resumen va en lo que devuelve el servicio (
{ created, skipped }) y queda endata.
Ejemplo en un controlador
@Controller('suppliers')
export class SupplierController {
constructor(private readonly supplierService: SupplierService) {}
// Consulta por GET: sin decorador → responde la entidad tal cual
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.supplierService.findOne(id);
}
// Consulta por POST: sin decorador y con @HttpCode(200) → { page, size, count, rows }
@Post('search')
@HttpCode(HttpStatus.OK)
search(@Body() pagedDto: SupplierPagedDto) {
return this.supplierService.search(pagedDto);
}
// Acción: → { message, data }
@Post()
@ResponseMessage(SupplierMessages.CREATED)
create(@Body() dto: CreateSupplierDto) {
return this.supplierService.create(dto);
}
// Acción que no es CRUD: mismo patrón
@Patch(':id/deactivate')
@ResponseMessage(SupplierMessages.DEACTIVATED)
deactivate(@Param('id', ParseUUIDPipe) id: string) {
return this.supplierService.deactivate(id);
}
// Acción sin retorno: → { message }
@Delete(':id')
@ResponseMessage(SupplierMessages.DELETED)
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.supplierService.remove(id);
}
// Acción con resumen: el mensaje es fijo y el detalle va en data
// → { message, data: { created: 3, skipped: 2 } }
@Post('import')
@ResponseMessage(SupplierMessages.IMPORTED)
import(@Body() dto: ImportSuppliersDto) {
return this.supplierService.import(dto);
}
}
- El decorador se pone en el handler, no en el
@Controller: un controlador mezcla consultas y acciones, y a nivel de clase el mensaje se aplicaría también a losGET. - El servicio de
importdevuelve{ created, skipped }y no arma ningún texto.
Formato de respuestas (no se construye a mano)
| Tipo de endpoint | Respuesta |
|---|---|
Consulta (GET o POST de búsqueda) |
El resultado tal cual: entidad, arreglo o { page, size, count, rows } |
| Acción que devuelve algo | { "message": "...", "data": ... } |
Acción que no devuelve nada (void) |
{ "message": "..." } |
| Error | { "statusCode", "code", "message", "timestamp", "path" } |
messagede error es unstring[]cuando el error viene deValidationPipe.pathno incluye el query string.- No agregar
timestampnipatha las respuestas exitosas: el cliente ya los conoce. En los errores sirven para cruzar un error reportado con los logs. - Campos sensibles en entidades:
@Exclude()declass-transformer. ElClassSerializerInterceptorglobal lo aplica en consultas y acciones. - Entidad o response DTO:
- CRUD simple: devolver la entidad, con
@Exclude()en lo que no debe salir. - Response DTO (
<feature>/dto/<feature>-response.dto.ts) cuando el recurso tiene campos calculados o de relaciones, o datos sensibles (usuarios, credenciales). Ahí una lista explícita de lo que sale es más segura que acordarse del@Exclude()en cada columna nueva. - Mapeo con una función (
toSupplierResponse(entity)) oplainToInstance(Dto, entity, { excludeExtraneousValues: true })+@Expose(). Sin automapper. - ❌ Decoradores de
class-validatoren un DTO de respuesta: la validación solo corre sobre lo que entra.
- CRUD simple: devolver la entidad, con
DTOs, interfaces y dónde vive cada tipo
- Clase en
dto/cuando algo la usa en tiempo de ejecución:- lo que entra y se valida (
@Body(),@Query(),@Param()) conclass-validator; - lo que se transforma con
class-transformer(@Expose({ name: 'access_token' })al leer la respuesta de un servicio externo,@Exclude()); - lo que documentará Swagger (
@ApiPropertynecesita clases).
- lo que entra y se valida (
- Interfaz en
interfaces/cuando es solo un tipo en tiempo de compilación: formas internas que nunca son body de entrada ni de salida (datos armados entre dos métodos, el sobre que produce un interceptor o un filtro). Una clase sin decoradores no aporta nada. - Carpeta
dto/en singular. Nombres:create-<feature>.dto.ts,<feature>-response.dto.ts, y para respuestas de APIs externas el nombre del proveedor (<provider>-token-response.dto.ts). - El tipo vive con quien lo define, no con quien lo usa primero. Si algo de
common/produce una forma (el sobre{ token, nonce, exp }de una utilidad de cifrado, elMessageResponsedel interceptor), su interfaz va encommon/junto a ese código, aunque hoy solo la consumaauth. Asícommon/nunca importa de un módulo de negocio y no se forma el ciclocommon → auth → common. - Si un tipo lo necesitan dos módulos de negocio, lo exporta el módulo dueño del concepto y el otro lo importa en esa dirección. Se mueve a
common/solo si de verdad es transversal. - Lo de otra integración no se mete en
authpor parecerse: el login de clientes externos (clientId/clientSecret) va en su propio módulo. MessageResponse,ApiErrorResponseyErrorDefinitionson interfaces porque nadie los instancia ni valida. Cuando se agregue Swagger,ApiErrorResponsepasa a clase con@ApiProperty;MessageResponse<T>necesita además un decorador helper (ApiExtraModels+getSchemaPath), porque Swagger no entiende genéricos de clase.
Configuración
- Variable nueva: agregarla a
EnvironmentVariables, con tipo explícito aunque tenga valor por defecto (PORT: number = 3033), y a.env.template. - Leer con
config.get('VAR', { infer: true })usandoConfigService<EnvironmentVariables, true>. - Configuración de un módulo externo:
forRootAsync({ inject: [ConfigService], useFactory })con la factory ensrc/config/.
Base de datos
- Opciones del pool de
pg:poolSize(se traduce amax) yextracon opciones de node-postgres. Nunca opciones de MySQL (connectionLimit,timezone). - Una sola tabla: repositorio de TypeORM inyectado. Varias escrituras que deben ir juntas:
dataSource.transaction(...). - Evitar N+1:
relationsoQueryBuilderconleftJoinAndSelect.
Checklist para un módulo nuevo
nest g resource <feature>(o copiar la plantilla) y mover los archivos acontrollers/,services/,dto/,entities/.- Crear
constants/<feature>.errors.tscon al menosNOT_FOUND, yconstants/<feature>.messages.tscon un mensaje por acción. - DTOs con
class-validator;UpdateDto extends PartialType(CreateDto)desde@nestjs/mapped-types. - Servicio que lanza
DomainExceptiony devuelve entidades. - Controlador sin try/catch ni formato manual:
@ResponseMessageen cada acción,@HttpCode(HttpStatus.OK)en cada consulta porPOST. TypeOrmModule.forFeature([Entity])en el módulo; exportar el servicio solo si otro módulo lo usa.- Registrar el módulo en
app.module.ts. pnpm exec tsc --noEmit,pnpm run lintypnpm run buildsin errores.
Reglas relacionadas de nestjs-best-practices
arch-feature-modules, arch-avoid-circular-deps, arch-module-sharing, error-use-exception-filters, error-throw-http-exceptions, api-use-interceptors, api-use-dto-serialization, api-use-pipes, security-validate-all-input, devops-use-config-module, db-avoid-n-plus-one, db-use-transactions.