NestJS para Desarrolladores Rails
De ApplicationController a Arquitectura Modular
Tiempo estimado: 4-6 horas | Nivel: Intermedio | Prerrequisitos: Rails, TypeScript básico
📋 Índice
- 🎯 Objetivo y Quick Start (15 min)
- 🏗️ Arquitectura: Rails vs NestJS (30 min)
- ⚡ Setup y primer recurso (45 min)
- 🔍 Inyección de dependencias (30 min)
- ✅ DTOs y validación avanzada (60 min)
- 🛡️ Guards, Interceptors y Middleware (90 min)
- 🧪 Testing completo (60 min)
- 🚀 Deployment y producción (30 min)
- 💪 Ejercicios prácticos (120 min)
🎯 Objetivo y Quick Start
Objetivo: Dominar NestJS aprovechando tu experiencia en Rails, entendiendo las diferencias arquitecturales y adoptando el mindset de inyección de dependencias.
Quick Start (5 minutos)
# Instalar NestJS CLI
npm i -g @nestjs/cli
# Crear proyecto
nest new blog-api
cd blog-api
# Instalar dependencias esenciales
npm install class-validator class-transformer
# Generar recurso completo
nest g resource posts
# Ejecutar
npm run start:dev
✅ Checkpoint: Visita http://localhost:3000/posts
- deberías ver un JSON vacío.
🏗️ Arquitectura: Rails vs NestJS
Comparación mental
Rails | NestJS | Propósito |
---|---|---|
ApplicationController |
AppModule |
Punto de entrada de la app |
Controller | Controller | Manejar requests HTTP |
Service Objects/POROs | Providers/Services | Lógica de negocio |
before_action |
Guards | Autenticación/Autorización |
around_action |
Interceptors | Middleware personalizado |
Strong Parameters | DTOs + Validation | Validación de entrada |
Initializers | Modules | Configuración y dependencias |
Flujo de request
graph LR
A[HTTP Request] --> B[Guard]
B --> C[Interceptor Before]
C --> D[Controller]
D --> E[Service]
E --> F[Interceptor After]
F --> G[HTTP Response]
En Rails: Request → before_action → Controller → Service → around_action → Response
En NestJS: Request → Guard → Interceptor → Controller → Service → Interceptor → Response
Diferencias clave
Rails (Convention over Configuration):
# Todo implícito, basado en convenciones
class PostsController < ApplicationController
def create
@post = PostService.new.create(post_params) # Instanciación manual
end
end
NestJS (Explicit Dependencies):
// Todo explícito, basado en decoradores
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {} // DI automática
@Post()
create(@Body() createPostDto: CreatePostDto) {
return this.postsService.create(createPostDto);
}
}
⚡ Setup y primer recurso
Estructura del proyecto
src/
├── app.controller.ts # Como routes.rb + ApplicationController
├── app.module.ts # Como config/application.rb
├── app.service.ts # Service base
├── main.ts # Como config/boot.rb
└── posts/ # Módulo generado
├── dto/
│ ├── create-post.dto.ts
│ └── update-post.dto.ts
├── posts.controller.ts
├── posts.module.ts
├── posts.service.ts
└── entities/
└── post.entity.ts
Anatomía de los archivos generados
posts.module.ts (Como un Rails Engine):
@Module({
controllers: [PostsController], // Registra controllers
providers: [PostsService], // Registra services para DI
exports: [PostsService] // Hace service disponible para otros módulos
})
export class PostsModule {}
posts.controller.ts (Familiar, pero con decoradores):
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Post() // Equivale a: post '/posts'
@Get() // Equivale a: get '/posts'
@Get(':id') // Equivale a: get '/posts/:id'
@Patch(':id') // Equivale a: patch '/posts/:id'
@Delete(':id') // Equivale a: delete '/posts/:id'
}
posts.service.ts (Como un Service Object):
@Injectable() // Marca la clase como inyectable
export class PostsService {
private posts: Post[] = []; // En Rails sería un modelo/repositorio
create(createPostDto: CreatePostDto): Post {
const post = { id: Date.now(), ...createPostDto };
this.posts.push(post);
return post;
}
}
🔍 Inyección de dependencias
Rails vs NestJS: Gestión de dependencias
Rails (Manual y implícita):
class PostsController < ApplicationController
def initialize
@post_service = PostService.new
@email_service = EmailService.new
@logger = Rails.logger
end
end
NestJS (Automática y explícita):
@Controller('posts')
export class PostsController {
constructor(
private readonly postsService: PostsService,
private readonly emailService: EmailService,
private readonly logger: Logger
) {}
// NestJS inyecta automáticamente las dependencias
}
Tipos de providers
1. Valor constante:
@Module({
providers: [
{
provide: 'API_KEY',
useValue: process.env.API_KEY
}
]
})
2. Factory (como Rails initializer):
@Module({
providers: [
{
provide: 'DATABASE_CONNECTION',
useFactory: () => {
return new DatabaseConnection(process.env.DATABASE_URL);
}
}
]
})
3. Clase alternativa:
@Module({
providers: [
{
provide: PostsService,
useClass: MockPostsService // Para testing
}
]
})
Inyección personalizada
@Injectable()
export class PostsService {
constructor(
@Inject('API_KEY') private readonly apiKey: string,
@Inject('DATABASE_CONNECTION') private readonly db: any
) {}
}
✅ DTOs y validación avanzada
Rails Strong Parameters vs NestJS DTOs
Rails:
def post_params
params.require(:post).permit(:title, :body, tags: [])
end
NestJS:
export class CreatePostDto {
@IsString()
@Length(3, 120)
title: string;
@IsString()
@Length(1, 10000)
body: string;
@IsArray()
@IsString({ each: true })
@ArrayMinSize(1)
tags: string[];
}
Configuración global de validación
main.ts:
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Equivale a configurar strong parameters globalmente
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Solo permite propiedades del DTO
forbidNonWhitelisted: true, // Rechaza propiedades extra
transform: true, // Convierte tipos automáticamente
disableErrorMessages: false // En prod, cambiar a true
}));
await app.listen(3000);
}
Validaciones avanzadas
Validaciones condicionales:
export class CreatePostDto {
@IsString()
title: string;
@IsString()
body: string;
@IsOptional()
@IsDateString()
@ValidateIf(o => o.status === 'scheduled')
publishedAt?: string;
@IsIn(['draft', 'published', 'scheduled'])
status: string;
}
Validaciones personalizadas:
import { registerDecorator, ValidationOptions } from 'class-validator';
export function IsNotProfane(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'isNotProfane',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any) {
const profaneWords = ['spam', 'bad'];
return typeof value === 'string' && !profaneWords.some(word =>
value.toLowerCase().includes(word)
);
},
defaultMessage() {
return 'El contenido contiene palabras no permitidas';
}
},
});
};
}
export class CreatePostDto {
@IsString()
@IsNotProfane({ message: 'El título no puede contener contenido inapropiado' })
title: string;
}
Transformación de datos:
import { Transform } from 'class-transformer';
export class CreatePostDto {
@IsString()
@Transform(({ value }) => value.trim().toLowerCase())
title: string;
@IsArray()
@Transform(({ value }) => value.filter(tag => tag.trim().length > 0))
tags: string[];
}
Manejo de errores de validación
// custom-validation.pipe.ts
@Injectable()
export class CustomValidationPipe extends ValidationPipe {
public createExceptionFactory() {
return (validationErrors: ValidationError[] = []) => {
const errors = this.flattenValidationErrors(validationErrors);
return new BadRequestException({
message: 'Validation failed',
errors: errors,
timestamp: new Date().toISOString(),
});
};
}
private flattenValidationErrors(validationErrors: ValidationError[]): any {
return validationErrors.reduce((acc, error) => {
acc[error.property] = Object.values(error.constraints);
return acc;
}, {});
}
}
🛡️ Guards, Interceptors y Middleware
Guards: Como before_action
pero más potente
Rails before_action
:
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :check_api_key, only: [:api_method]
end
NestJS Guard:
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization;
// Lógica de validación
return this.validateToken(token);
}
private validateToken(token: string): boolean {
return token === `Bearer ${process.env.API_TOKEN}`;
}
}
Uso del Guard:
@Controller('posts')
@UseGuards(AuthGuard) // Aplica a todo el controller
export class PostsController {
@Post()
@UseGuards(AdminGuard) // Aplica solo a este endpoint
create(@Body() createPostDto: CreatePostDto) {
return this.postsService.create(createPostDto);
}
}
Guards avanzados con inyección de dependencias
@Injectable()
export class RolesGuard implements CanActivate {
constructor(
private readonly usersService: UsersService,
private readonly reflector: Reflector
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
if (!requiredRoles) return true;
const request = context.switchToHttp().getRequest();
const user = await this.usersService.findByToken(request.headers.authorization);
return requiredRoles.some(role => user?.roles?.includes(role));
}
}
// Custom decorator
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
// Uso
@Controller('admin')
@UseGuards(RolesGuard)
export class AdminController {
@Post('posts')
@Roles('admin', 'editor')
createPost() {
// Solo admin y editor pueden acceder
}
}
Interceptors: Como around_action
pero más versátil
Rails around_action
:
around_action :measure_time
private
def measure_time
start_time = Time.current
yield
Rails.logger.info "Request took #{Time.current - start_time}s"
end
NestJS Interceptor:
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const method = request.method;
const url = request.url;
const start = Date.now();
console.log(`→ ${method} ${url}`);
return next.handle().pipe(
tap((data) => {
console.log(`← ${method} ${url} - ${Date.now() - start}ms`);
}),
catchError((error) => {
console.log(`✗ ${method} ${url} - ERROR: ${error.message}`);
throw error;
})
);
}
}
Interceptor de transformación de respuesta:
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, any> {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map(data => ({
success: true,
data,
timestamp: new Date().toISOString(),
path: context.switchToHttp().getRequest().url
}))
);
}
}
// Respuesta transformada:
// {
// "success": true,
// "data": { "id": 1, "title": "Mi post" },
// "timestamp": "2025-01-20T10:30:00.000Z",
// "path": "/posts"
// }
Middleware global
// logger.middleware.ts
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const { method, originalUrl } = req;
const start = Date.now();
res.on('finish', () => {
const { statusCode } = res;
const duration = Date.now() - start;
console.log(`${method} ${originalUrl} ${statusCode} - ${duration}ms`);
});
next();
}
}
// app.module.ts
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes('*'); // Aplica a todas las rutas
}
}
Orden de ejecución
Request
↓
Middleware Global
↓
Guards
↓
Interceptors (before)
↓
Pipes (validation)
↓
Controller
↓
Service
↓
Interceptors (after)
↓
Response
🧪 Testing completo
Configuración de testing
Rails: rails_helper.rb
+ spec_helper.rb
NestJS: Jest configurado automáticamente
Tests unitarios de servicios
// posts.service.spec.ts
describe('PostsService', () => {
let service: PostsService;
let mockRepository: jest.Mocked<PostsRepository>;
beforeEach(async () => {
const mockRepo = {
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
delete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
PostsService,
{ provide: PostsRepository, useValue: mockRepo }
],
}).compile();
service = module.get<PostsService>(PostsService);
mockRepository = module.get(PostsRepository);
});
describe('create', () => {
it('should create and return a post', async () => {
const createPostDto = { title: 'Test Post', body: 'Content' };
const expectedPost = { id: 1, ...createPostDto };
mockRepository.save.mockResolvedValue(expectedPost);
const result = await service.create(createPostDto);
expect(mockRepository.save).toHaveBeenCalledWith(createPostDto);
expect(result).toEqual(expectedPost);
});
it('should throw error if title is empty', async () => {
const createPostDto = { title: '', body: 'Content' };
await expect(service.create(createPostDto)).rejects.toThrow(
'Title cannot be empty'
);
});
});
});
Tests de integración de controladores
// posts.controller.spec.ts
describe('PostsController', () => {
let controller: PostsController;
let service: PostsService;
beforeEach(async () => {
const mockService = {
create: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [PostsController],
providers: [{ provide: PostsService, useValue: mockService }],
}).compile();
controller = module.get<PostsController>(PostsController);
service = module.get<PostsService>(PostsService);
});
describe('create', () => {
it('should create a post', async () => {
const createPostDto = { title: 'Test', body: 'Content' };
const expectedResult = { id: 1, ...createPostDto };
jest.spyOn(service, 'create').mockResolvedValue(expectedResult);
const result = await controller.create(createPostDto);
expect(service.create).toHaveBeenCalledWith(createPostDto);
expect(result).toBe(expectedResult);
});
});
});
Tests E2E con supertest
// app.e2e-spec.ts
describe('PostsController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
// Aplicar los mismos pipes que en producción
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
}));
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('POST /posts', () => {
it('should create a post', () => {
return request(app.getHttpServer())
.post('/posts')
.send({ title: 'Test Post', body: 'Test content' })
.expect(201)
.expect((res) => {
expect(res.body.title).toBe('Test Post');
expect(res.body.id).toBeDefined();
});
});
it('should validate required fields', () => {
return request(app.getHttpServer())
.post('/posts')
.send({ title: 'Hi' }) // body missing
.expect(400)
.expect((res) => {
expect(res.body.message).toContain('validation failed');
});
});
it('should reject extra fields', () => {
return request(app.getHttpServer())
.post('/posts')
.send({
title: 'Test',
body: 'Content',
hacker: 'field' // Campo no permitido
})
.expect(400);
});
});
});
Testing Guards y Interceptors
// auth.guard.spec.ts
describe('AuthGuard', () => {
let guard: AuthGuard;
let mockExecutionContext: ExecutionContext;
beforeEach(() => {
guard = new AuthGuard();
mockExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: { authorization: 'Bearer valid-token' }
})
})
} as ExecutionContext;
});
it('should allow access with valid token', () => {
const result = guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
});
it('should deny access without token', () => {
mockExecutionContext.switchToHttp().getRequest = () => ({
headers: {}
});
const result = guard.canActivate(mockExecutionContext);
expect(result).toBe(false);
});
});
🚀 Deployment y producción
Build optimizado
# Build para producción
npm run build
# Archivos generados en dist/
Variables de entorno
// config/configuration.ts
export default () => ({
port: parseInt(process.env.PORT, 10) || 3000,
database: {
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '1d',
},
});
// app.module.ts
@Module({
imports: [
ConfigModule.forRoot({
load: [configuration],
isGlobal: true,
}),
],
})
export class AppModule {}
Docker setup
# Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/main"]
# docker-compose.yml
version: '3.8'
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://user:pass@db:5432/blog
depends_on:
- db
db:
image: postgres:15
environment:
- POSTGRES_DB=blog
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Configuración para producción
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// CORS
app.enableCors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
credentials: true,
});
// Security headers
app.use(helmet());
// Rate limiting
app.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 100, // máximo 100 requests por IP
}),
);
// Global validation
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
disableErrorMessages: process.env.NODE_ENV === 'production',
}));
// Global error handling
app.useGlobalFilters(new AllExceptionsFilter());
const port = process.env.PORT || 3000;
await app.listen(port);
}
💪 Ejercicios prácticos
🔥 Ejercicio 1: CRUD con validaciones (30 min)
Implementa un CRUD completo para Posts
con:
- Validación de título (3-120 chars)
- Validación de contenido (mínimo 10 chars)
- Campo
status
: draft, published, archived - Campo
tags
(array de strings)
// Starter code
export class CreatePostDto {
// TODO: Añadir validaciones
title: string;
body: string;
status: string;
tags: string[];
}
🔥 Ejercicio 2: Sistema de autenticación (45 min)
Crea un Guard que:
- Valide un header
X-API-Key
- Permita ciertos endpoints sin autenticación
- Registre intentos de acceso no autorizado
// Starter code
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
// TODO: Implementar lógica
return true;
}
}
🔥 Ejercicio 3: Interceptor de métricas (30 min)
Crea un Interceptor que:
- Mida tiempo de respuesta
- Cuente requests por endpoint
- Añada headers personalizados
🔥 Ejercicio 4: Sistema de Posts con Comentarios (60 min)
Implementa una relación uno-a-muchos:
- Posts tienen múltiples Comments
- Comments pertenecen a un Post
- CRUD completo para ambos
- Validaciones y tests
Estructura esperada:
/posts/:id/comments
GET, POST, PUT, DELETE
🔥 Ejercicio 5: Rate Limiting personalizado (45 min)
Crea un sistema de rate limiting que:
- Limite por IP y por API key diferente
- Tenga diferentes límites por endpoint
- Guarde métricas en memoria
✅ Checklist final
Básico
- [ ] Entiendo la diferencia entre Module, Controller y Service
- [ ] Puedo crear DTOs con validaciones complejas
- [ ] Sé cuándo usar Guards vs Interceptors vs Middleware
- [ ] Comprendo la inyección de dependencias
Intermedio
- [ ] Puedo crear Guards personalizados con lógica compleja
- [ ] Implemento Interceptors que transforman requests/responses
- [ ] Escribo tests unitarios y de integración
- [ ] Manejo errores globalmente
Avanzado
- [ ] Configuré un pipeline de deployment con Docker
- [ ] Implementé rate limiting y security headers
- [ ] Creé un sistema de logging y métricas
- [ ] Optimicé la aplicación para producción
🐛 Troubleshooting común
Error: "Circular dependency"
typescript
// ❌ Problema
@Injectable()
export class PostsService {
constructor(private usersService: UsersService) {}
}
@Injectable()
export class UsersService {
Top comments (0)