Portafolio profesional
Construyendo un CMS Backend para Portafolios con NestJS y PostgreSQL
Cómo diseñé una API modular de CMS para portafolios personales, resolviendo problemas de gestión de contenido manual y escalabilidad con TypeScript, Prisma y Docker.
Artículo
# Construyendo el backend de mi portafolio: un CMS modular con NestJS y Prisma
## El problema
Como desarrollador backend, necesitaba un portafolio que fuera más que una página estática. Quería poder actualizar proyectos, agregar nuevas tecnologías y recibir mensajes de contacto sin tener que tocar código cada vez. El proceso manual de editar archivos HTML o JSON se volvía tedioso, especialmente cuando quería mostrar métricas dinámicas como estrellas de GitHub o estado de proyectos. Además, necesitaba un sistema que pudiera escalar si decidía agregar un blog técnico o gestionar múltiples tipos de contenido.
## Por qué NestJS + Prisma
Podría haber usado Express directamente -es más ligero y tengo experiencia con él- pero para un CMS que maneja múltiples recursos con relaciones complejas (proyectos ↔ tecnologías, artículos ↔ etiquetas), necesitaba una estructura que me obligara a ser organizado desde el principio. NestJS me dio eso: módulos separados, inyección de dependencias nativa y decoradores que hacen el código más declarativo.
Prisma fue la elección natural sobre TypeORM o Sequelize porque su cliente generado automáticamente me ahorraba escribir interfaces manualmente para cada modelo. Con proyectos que tienen tecnologías asociadas y artículos con traducciones, tener un esquema centralizado en `schema.prisma` y tipos TypeScript generados automáticamente redujo errores y tiempo de desarrollo. Además, las migraciones son más predecibles.
## Arquitectura
El sistema sigue una arquitectura por capas clásica pero con algunos twists específicos para un CMS:
```mermaid
flowchart LR
Client["Browser/Postman
Frontend o pruebas"] -- HTTP request --> Gateway["NestJS Controllers
Rutas API v1"]
Gateway -- valida y transforma --> Services["Services Layer
Lógica de negocio"]
Services -- queries --> DB["PostgreSQL
Prisma Client"]
Services -- envía --> Mailer["Nodemailer
Envío de emails"]
Services -- escribe logs --> Analytics["PostgreSQL
Tablas de métricas"]
DB -- seed inicial --> Seed["prisma/seed.ts
Datos iniciales"]
NOTE["Evidence gap: No hay evidencia de
colas de mensajes o caché"]
```
El flujo es directo: las peticiones HTTP llegan a los controladores de NestJS, que validan los DTOs usando `class-validator`. Luego delegan a la capa de servicios donde ocurre la lógica real -crear proyectos, asociar tecnologías, procesar contactos- y finalmente usan el Prisma Client para persistir en PostgreSQL. Un punto interesante es que las métricas de solicitudes y logs del servidor también van a tablas dedicadas en la misma base de datos, no a un sistema externo.
**Decisión 1: Una sola base de datos para todo**
Podría haber separado las métricas y logs en otra DB o usar un servicio como Datadog, pero para un portafolio personal, mantener todo en PostgreSQL simplifica el despliegue y los backups. Las tablas `RequestLog` y `ServerMetrics` conviven con `Project` y `ContactMessage`. El trade-off es potencial impacto en performance, pero para el volumen esperado es aceptable.
**Decisión 2: Seed automático con Prisma**
Incluí un script `prisma/seed.ts` que pobla la base de datos con proyectos de ejemplo y un usuario admin. Esto es crucial para demostraciones y desarrollo local. En producción, este seed solo corre en el primer deploy o cuando se indica explícitamente.
**Decisión 3: Validación estricta con class-validator**
Cada endpoint usa DTOs con decoradores como `@IsString()`, `@IsOptional()`, `@IsArray()`. NestJS tiene un pipe global `ValidationPipe` configurado con `whitelist: true` y `forbidNonWhitelisted: true`, lo que significa que si el cliente manda campos extra, la API los rechaza. Esto previene ataques de polución y asegura consistencia.
## El trade-off que nadie te cuenta
El mayor trade-off fue **decidir cómo manejar las traducciones para proyectos y artículos**. Inicialmente diseñé una tabla `Translation` separada con `locale` y `text`, y relaciones muchos-a-muchos con `Project` y `Article`. Esto era elegante en teoría pero engorroso en la práctica: cada consulta requería joins complejos, y crear un proyecto nuevo implicaba múltiples inserciones.
Después de varias iteraciones (mirá las migraciones en `prisma/migrations/20260501204229_remove_translations` y `20260710172923_add_i18n_to_articles_and_diagrams`), terminé con una solución híbrida: uso un DTO `I18nTextDto` que se almacena como JSON en la base de datos. Por ejemplo:
```typescript
// src/common/dto/i18n-text.dto.ts
export class I18nTextDto {
@IsObject()
translations: Record<string, string>; // { 'es': '...', 'en': '...' }
}
```
Esto simplifica enormemente las consultas -solo fetch del campo JSON- pero pierde la capacidad de hacer búsquedas eficientes por texto en un idioma específico a nivel de base de datos. Para mi caso de uso, donde el contenido es gestionado por mí y no por miles de usuarios, fue un trade-off aceptable. Si necesitara búsqueda full-text multilingüe, reconsideraría la decisión.
## Resultado
- **API completa con 10+ endpoints** para proyectos, contactos, medios y métricas, documentada automáticamente con Swagger en `/api/v1/docs`.
- **Sistema de contactos con auto-respuesta**: cuando alguien envía un mensaje, recibe un email de confirmación y yo recibo una notificación, todo usando plantillas Handlebars.
- **Métricas integradas**: cada request se loguea en la DB con timestamp, método, ruta y status code, lo que me permite analizar tráfico sin herramientas externas.
- **Despliegue con Docker**: un `docker-compose.yml` levanta PostgreSQL y la app en segundos, ideal para desarrollo y producción en VPS.
## Lo que haría diferente
Separaría más claramente la lógica de negocio de la infraestructura. Algunos servicios como `ProjectService` manejan validación, transformación de datos, y llamadas a Prisma. En un proyecto más grande, dividiría en `ProjectRepository` (solo acceso a DB), `ProjectValidator` (reglas de negocio) y `ProjectService` (orquestación). También consideraría usar `bull` o `@nestjs/bull` para colas de email asíncronas, aunque para el volumen actual no es crítico.
---
¿Querés profundizar en algún componente? Contactame o revisá el código en https://github.com/Albarracin-sg/BACKEND-PORTAFOLIO.