De ApplicationController al Event Loop
Tiempo estimado: 6-8 horas | Nivel: Básico-Intermedio | Prerrequisitos: Rails sólido, JavaScript básico
📋 Índice y objetivos
- 🎯 Por qué esta semana es crucial (15 min)
- ⚡ Setup del entorno (30 min)
- 🔧 TypeScript esencial para Rails devs (60 min)
- 🌊 Event Loop vs Rails Threading (45 min)
- 🏗️ HTTP Server desde cero (30 min)
- 🚂 Express: El Rack de Node (90 min)
- 🧪 Testing con Jest (60 min)
- ✅ Validación antes de NestJS (45 min)
- 💪 Ejercicios prácticos (120 min)
Objetivo principal: Construir el foundation mental y técnico para entender NestJS desde una perspectiva Rails, dominando los conceptos fundamentales de Node y TypeScript.
🎯 Por qué esta semana es crucial
Diferencias arquitecturales clave
Rails + Puma | Node.js | Implicación práctica |
---|---|---|
Multi-thread por defecto | Single-thread + Event Loop | Un bloqueo = toda la app se congela |
IO bloqueante (transparente) | IO no-bloqueante (explícito) | Debes pensar en async/await siempre |
Convención sobre configuración | Configuración explícita | Control total, pero más decisiones |
Rack middleware stack | Express/Connect middleware | Conceptos similares, API diferente |
Modelo mental fundamental
Rails: "Cada request tiene su hilo, puedo bloquear tranquilo"
# En Rails esto es normal:
def show
@user = User.find(params[:id]) # Query DB (bloquea 50ms)
@posts = @user.posts.includes(:tags) # Otra query (bloquea 30ms)
render :show # Total: ~80ms bloqueado
end
Node: "Un solo hilo para todos, debo ceder control constantemente"
// En Node debes pensar así:
async function show(req, res) {
const user = await User.findById(req.params.id); // Cede control
const posts = await user.getPostsWithTags(); // Cede control
res.json({ user, posts }); // ~80ms total, sin bloquear
}
Key insight: En Node, cada await
es como un "yield" que permite que otros requests se procesen.
⚡ Setup del entorno
Instalación base
# Verificar Node LTS
node -v # Necesitas >=18.x (LTS para estabilidad)
npm -v # Viene incluido con Node
# Setup del proyecto
mkdir rails-to-node-week1
cd rails-to-node-week1
# Usar pnpm (más eficiente que npm)
npm install -g pnpm
pnpm init -y
# Dependencias de desarrollo
pnpm add -D typescript ts-node @types/node
pnpm add -D eslint prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin
pnpm add -D jest ts-jest @types/jest
# Dependencias de runtime
pnpm add express zod
pnpm add -D @types/express supertest @types/supertest
Configuración inicial
tsconfig.json (enfocado en lo esencial):
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}
package.json scripts:
{
"scripts": {
"build": "tsc",
"dev": "ts-node --esm src/server.ts",
"start": "node dist/server.js",
"test": "jest",
"test:watch": "jest --watch",
"format": "prettier --write 'src/**/*.ts'",
"lint": "eslint src/**/*.ts"
}
}
Jest config (jest.config.js
):
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/test'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
collectCoverageFrom: ['src/**/*.ts'],
};
Comparación Rails: El setup es como configurar un Gemfile
, config/application.rb
y spec_helper.rb
desde cero, pero con más control granular.
🔧 TypeScript esencial para Rails devs
Tipado estructural vs nominal
En Ruby (nominal): las clases importan
class User
attr_accessor :id, :email
end
class AdminUser < User
attr_accessor :admin
end
# AdminUser ES-UN User por herencia
En TypeScript (estructural): la forma importa
// Si tiene la misma "forma", es compatible
interface User {
id: number;
email: string;
}
interface Admin {
id: number;
email: string;
admin: boolean;
}
// Admin es compatible con User automáticamente (duck typing extremo)
function processUser(user: User) {
console.log(user.email);
}
const admin: Admin = { id: 1, email: 'admin@test.com', admin: true };
processUser(admin); // ✅ Funciona sin problema
Equivalencias Rails → TypeScript
// src/types.ts
// Como un PORO con attr_accessor
export interface User {
id: number;
email: string;
createdAt: Date;
admin?: boolean; // ? = opcional (como un attr con default nil)
}
// Como strong parameters definidos
export type CreateUserParams = {
email: string;
password: string;
};
// Como un Service Object genérico
export interface Repository<T> {
find(id: number): Promise<T | null>;
create(params: Partial<T>): Promise<T>;
update(id: number, params: Partial<T>): Promise<T>;
destroy(id: number): Promise<void>;
}
// Union types = como enums pero más flexibles
export type UserStatus = 'active' | 'inactive' | 'banned';
// Genéricos = como módulos parametrizados
export interface ApiResponse<T> {
data: T;
success: boolean;
errors?: string[];
}
// Utility types = como meta-programación Ruby
export type UserUpdate = Partial<User>; // Todos los campos opcionales
export type UserCreate = Omit<User, 'id'>; // User sin el id
export type UserEmail = Pick<User, 'email'>; // Solo el email
Type guards y narrowing
// src/type-guards.ts
// Como un método de validación en Ruby
export function isValidEmail(value: unknown): value is string {
return typeof value === 'string' && value.includes('@');
}
// Refinamiento de tipos (como rescue/ensure pero para tipos)
export function processInput(input: string | number | undefined) {
if (typeof input === 'undefined') {
return 'No input provided';
}
if (typeof input === 'string') {
return input.toUpperCase(); // TS sabe que es string aquí
}
return input * 2; // TS sabe que es number aquí
}
// Assertion personalizada
export function assertIsUser(value: unknown): asserts value is User {
if (!value || typeof value !== 'object') {
throw new Error('Not a valid user object');
}
const obj = value as any;
if (typeof obj.id !== 'number' || typeof obj.email !== 'string') {
throw new Error('Invalid user structure');
}
}
Errores comunes viniendo de Rails
// ❌ MAL: Usar any (como no validar params en Rails)
function badFunction(data: any) {
return data.something.nested.deep; // Puede explotar en runtime
}
// ✅ BIEN: Ser explícito con unknown y validar
function goodFunction(data: unknown) {
if (typeof data === 'object' && data !== null && 'something' in data) {
// Ahora puedes acceder de forma segura
return (data as any).something;
}
throw new Error('Invalid data structure');
}
// ❌ MAL: Confiar en librerías sin tipos
const someLib = require('some-untyped-lib'); // No hay autocompletado ni validación
// ✅ BIEN: Usar @types o crear declaraciones
import someLib from 'some-untyped-lib';
// O crear types/some-lib.d.ts con las declaraciones
🌊 Event Loop vs Rails Threading
Visualización del Event Loop
// src/event-loop-demo.ts
console.log('1. Sync start');
// Macro task (timer)
setTimeout(() => console.log('2. setTimeout 0ms'), 0);
// Micro task (promise)
Promise.resolve().then(() => console.log('3. Promise resolved'));
// I/O operation (file system)
import { readFile } from 'fs/promises';
readFile(__filename, 'utf8').then(() => console.log('4. File read'));
console.log('5. Sync end');
// Salida: 1, 5, 3, 2, 4
// Orden: sync → microtasks → macrotasks
Comparación práctica: Manejo de múltiples operaciones
Rails (blocking, multi-thread):
# Cada request bloquea su thread, pero hay múltiples threads
def dashboard
# Thread 1 - Request A
users = User.all # Bloquea 100ms
posts = Post.recent # Bloquea 50ms
stats = Stats.calculate # Bloquea 200ms
# Total: 350ms bloqueados, pero otros threads siguen trabajando
end
Node (non-blocking, single-thread):
// src/comparison-demo.ts
// ❌ MAL: Bloquear en serie (like Rails but worse)
async function dashboardBad() {
const users = await fetchUsers(); // 100ms
const posts = await fetchPosts(); // 50ms
const stats = await fetchStats(); // 200ms
return { users, posts, stats }; // 350ms total, bloquea TODO
}
// ✅ BIEN: Paralelizar operaciones
async function dashboardGood() {
const [users, posts, stats] = await Promise.all([
fetchUsers(), // Los 3 en paralelo
fetchPosts(),
fetchStats()
]);
return { users, posts, stats }; // ~200ms total (el más lento)
}
// 🔥 MEJOR: Con manejo de errores
async function dashboardBest() {
const results = await Promise.allSettled([
fetchUsers(),
fetchPosts(),
fetchStats()
]);
return {
users: results[0].status === 'fulfilled' ? results[0].value : [],
posts: results[1].status === 'fulfilled' ? results[1].value : [],
stats: results[2].status === 'fulfilled' ? results[2].value : null,
};
}
Event Loop Best Practices
// src/event-loop-best-practices.ts
// ✅ Para operaciones CPU intensivas: usar Worker Threads
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
if (isMainThread) {
// Main thread - no bloquear
export function calculateHeavyTask(data: number[]): Promise<number> {
return new Promise((resolve, reject) => {
const worker = new Worker(__filename, { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
});
}
} else {
// Worker thread - aquí sí podemos bloquear
const data = workerData;
const result = data.reduce((sum: number, num: number) => sum + num, 0);
parentPort?.postMessage(result);
}
// ✅ Para I/O: siempre usar versiones async
import { readFile } from 'fs/promises'; // ✅ async
import { readFileSync } from 'fs'; // ❌ sync, bloquea
// ✅ Batch operations para evitar callback hell
async function processMany<T, R>(
items: T[],
processor: (item: T) => Promise<R>,
batchSize: number = 10
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
}
return results;
}
🏗️ HTTP Server desde cero
Servidor básico (equivalente a Rack)
// src/basic-server.ts
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { parse } from 'url';
// Tipo para nuestras rutas (como Rails routes)
type RouteHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
type Routes = Map<string, RouteHandler>;
class BasicServer {
private routes: Routes = new Map();
// Como Rails route helper
get(path: string, handler: RouteHandler) {
this.routes.set(`GET:${path}`, handler);
return this;
}
post(path: string, handler: RouteHandler) {
this.routes.set(`POST:${path}`, handler);
return this;
}
// Como Rails middleware stack
private async handleRequest(req: IncomingMessage, res: ServerResponse) {
const method = req.method || 'GET';
const url = parse(req.url || '/', true);
const routeKey = `${method}:${url.pathname}`;
// Simple routing
const handler = this.routes.get(routeKey);
if (handler) {
try {
await handler(req, res);
} catch (error) {
res.statusCode = 500;
res.end(JSON.stringify({ error: 'Internal server error' }));
}
} else {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Not found' }));
}
}
listen(port: number, callback?: () => void) {
const server = createServer(this.handleRequest.bind(this));
server.listen(port, callback);
return server;
}
}
// Uso como Rails routes
const app = new BasicServer();
app.get('/health', (req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ status: 'ok', timestamp: new Date().toISOString() }));
});
app.get('/users', (req, res) => {
// Simular consulta async
setTimeout(() => {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ users: [{ id: 1, name: 'John' }] }));
}, 100);
});
// Como bin/rails server
if (require.main === module) {
app.listen(3000, () => {
console.log('🚀 Server running on http://localhost:3000');
console.log(' GET /health - Health check');
console.log(' GET /users - List users');
});
}
export { BasicServer };
Parsing de request body
// src/body-parser.ts
import { IncomingMessage } from 'http';
// Como Rails params parsing
export function parseBody(req: IncomingMessage): Promise<any> {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
try {
const contentType = req.headers['content-type'] || '';
if (contentType.includes('application/json')) {
resolve(JSON.parse(body));
} else if (contentType.includes('application/x-www-form-urlencoded')) {
const params = new URLSearchParams(body);
const result: Record<string, string> = {};
for (const [key, value] of params) {
result[key] = value;
}
resolve(result);
} else {
resolve(body);
}
} catch (error) {
reject(new Error('Invalid body format'));
}
});
req.on('error', reject);
});
}
🚂 Express: El Rack de Node
Setup básico con middleware stack
// src/express-server.ts
import express, { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
const app = express();
// Middleware stack (como Rails middleware)
// 1. Body parsing (como ActionDispatch::Request)
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// 2. Request ID (como Rails RequestId middleware)
app.use((req: Request, res: Response, next: NextFunction) => {
const reqId = randomUUID();
req.headers['x-request-id'] = reqId;
res.setHeader('X-Request-Id', reqId);
next();
});
// 3. Logging middleware (como Rails logger)
app.use((req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
const reqId = req.headers['x-request-id'];
console.log(`[${reqId}] → ${req.method} ${req.url}`);
// Interceptar el end de la respuesta
const originalEnd = res.end;
res.end = function(chunk?: any, encoding?: any, cb?: any) {
const duration = Date.now() - start;
console.log(`[${reqId}] ← ${res.statusCode} (${duration}ms)`);
return originalEnd.call(this, chunk, encoding, cb);
};
next();
});
// 4. CORS (como Rack::Cors)
app.use((req: Request, res: Response, next: NextFunction) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-Id');
if (req.method === 'OPTIONS') {
res.sendStatus(200);
return;
}
next();
});
Rutas y controladores
// src/controllers/users.controller.ts (como Rails controller)
import { Request, Response } from 'express';
// Simulamos un modelo (en Rails sería User.all, etc)
const users = [
{ id: 1, email: 'user1@example.com', createdAt: new Date() },
{ id: 2, email: 'user2@example.com', createdAt: new Date() },
];
export class UsersController {
// GET /users (como index action)
static async index(req: Request, res: Response) {
// Simulamos query async
await new Promise(resolve => setTimeout(resolve, 50));
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const paginatedUsers = users.slice(offset, offset + limit);
res.json({
users: paginatedUsers,
pagination: {
page,
limit,
total: users.length,
pages: Math.ceil(users.length / limit)
}
});
}
// GET /users/:id (como show action)
static async show(req: Request, res: Response) {
const id = parseInt(req.params.id);
const user = users.find(u => u.id === id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ user });
}
// POST /users (como create action)
static async create(req: Request, res: Response) {
const { email } = req.body;
if (!email || !email.includes('@')) {
return res.status(422).json({
error: 'Validation failed',
details: { email: 'is required and must be valid' }
});
}
const newUser = {
id: users.length + 1,
email,
createdAt: new Date()
};
users.push(newUser);
res.status(201).json({ user: newUser });
}
}
Montaje de rutas
// src/routes/index.ts (como config/routes.rb)
import { Router } from 'express';
import { UsersController } from '../controllers/users.controller';
const router = Router();
// Health check
router.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
});
// Users routes (como resources :users)
router.get('/users', UsersController.index);
router.get('/users/:id', UsersController.show);
router.post('/users', UsersController.create);
export { router };
// En express-server.ts
app.use('/api/v1', router);
Manejo de errores global
// src/middleware/error-handler.ts (como Rails rescue_from)
import { Request, Response, NextFunction } from 'express';
export interface AppError extends Error {
statusCode?: number;
isOperational?: boolean;
}
export function errorHandler(
error: AppError,
req: Request,
res: Response,
next: NextFunction
) {
const reqId = req.headers['x-request-id'];
console.error(`[${reqId}] ERROR:`, {
message: error.message,
stack: error.stack,
url: req.url,
method: req.method,
body: req.body
});
const statusCode = error.statusCode || 500;
const message = error.isOperational ? error.message : 'Internal server error';
res.status(statusCode).json({
error: message,
requestId: reqId,
timestamp: new Date().toISOString()
});
}
// Usar en express-server.ts
app.use(errorHandler);
🧪 Testing con Jest
Configuración y estructura
// test/helpers/test-server.ts
import express from 'express';
import { router } from '../../src/routes';
export function createTestApp() {
const app = express();
app.use(express.json());
app.use('/api/v1', router);
return app;
}
export const testUsers = [
{ id: 1, email: 'test1@example.com' },
{ id: 2, email: 'test2@example.com' },
];
Tests de integración (como Rails request specs)
// test/users.e2e.spec.ts
import request from 'supertest';
import { createTestApp } from './helpers/test-server';
describe('Users API', () => {
const app = createTestApp();
describe('GET /api/v1/users', () => {
it('devuelve lista de usuarios', async () => {
const response = await request(app)
.get('/api/v1/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('users');
expect(Array.isArray(response.body.users)).toBe(true);
expect(response.body).toHaveProperty('pagination');
});
it('maneja paginación correctamente', async () => {
const response = await request(app)
.get('/api/v1/users?page=1&limit=1')
.expect(200);
expect(response.body.pagination).toMatchObject({
page: 1,
limit: 1
});
});
it('incluye request ID en headers', async () => {
const response = await request(app)
.get('/api/v1/users')
.expect(200);
expect(response.headers['x-request-id']).toBeDefined();
});
});
describe('POST /api/v1/users', () => {
it('crea usuario con datos válidos', async () => {
const userData = { email: 'newuser@example.com' };
const response = await request(app)
.post('/api/v1/users')
.send(userData)
.expect('Content-Type', /json/)
.expect(201);
expect(response.body.user).toMatchObject({
email: userData.email,
id: expect.any(Number),
createdAt: expect.any(String)
});
});
it('rechaza email inválido', async () => {
const invalidData = { email: 'not-an-email' };
const response = await request(app)
.post('/api/v1/users')
.send(invalidData)
.expect('Content-Type', /json/)
.expect(422);
expect(response.body).toHaveProperty('error');
expect(response.body).toHaveProperty('details');
});
it('rechaza request sin email', async () => {
await request(app)
.post('/api/v1/users')
.send({})
.expect(422);
});
});
describe('GET /api/v1/users/:id', () => {
it('devuelve usuario existente', async () => {
const response = await request(app)
.get('/api/v1/users/1')
.expect(200);
expect(response.body.user).toMatchObject({
id: 1,
email: expect.any(String)
});
});
it('devuelve 404 para usuario inexistente', async () => {
await request(app)
.get('/api/v1/users/999')
.expect(404);
});
});
});
Tests unitarios de utilidades
// test/utils/body-parser.spec.ts
import { IncomingMessage } from 'http';
import { parseBody } from '../../src/utils/body-parser';
// Mock de IncomingMessage
function createMockRequest(data: string, contentType: string): IncomingMessage {
const EventEmitter = require('events');
const req = new EventEmitter() as IncomingMessage;
req.headers = { 'content-type': contentType };
// Simular llegada de datos
setTimeout(() => {
req.emit('data', Buffer.from(data));
req.emit('end');
}, 10);
return req;
}
describe('parseBody', () => {
it('parsea JSON correctamente', async () => {
const jsonData = '{"name": "John", "age": 30}';
const req = createMockRequest(jsonData, 'application/json');
const result = await parseBody(req);
expect(result).toEqual({ name: 'John', age: 30 });
});
it('parsea form data correctamente', async () => {
const formData = 'name=John&age=30';
const req = createMockRequest(formData, 'application/x-www-form-urlencoded');
const result = await parseBody(req);
expect(result).toEqual({ name: 'John', age: '30' });
});
it('maneja JSON inválido', async () => {
const invalidJson = '{"name": John}'; // JSON malformado
const req = createMockRequest(invalidJson, 'application/json');
await expect(parseBody(req)).rejects.toThrow('Invalid body format');
});
});
Setup de tests con lifecycle
// test/setup.ts
beforeEach(() => {
// Reset de estado antes de cada test
jest.clearAllMocks();
});
afterEach(() => {
// Cleanup después de cada test
});
beforeAll(async () => {
// Setup global (conexiones DB, etc)
});
afterAll(async () => {
// Cleanup global
});
✅ Validación antes de NestJS
Schema validation con Zod
// src/schemas/user.schema.ts (como Rails strong parameters + validation)
import { z } from 'zod';
export const CreateUserSchema = z.object({
email: z.string()
.email('Debe ser un email válido')
.min(5, 'Email muy corto')
.max(100, 'Email muy largo'),
password: z.string()
.min(8, 'Password debe tener al menos 8 caracteres')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 'Password debe contener mayúscula, minúscula y número'),
name: z.string()
.min(2, 'Nombre muy corto')
.max(50, 'Nombre muy largo')
.optional(),
age: z.number()
.int('Edad debe ser un número entero')
.min(18, 'Debe ser mayor de edad')
.max(120, 'Edad no válida')
.optional()
});
export const UpdateUserSchema = CreateUserSchema.partial(); // Todos los campos opcionales
export const UserParamsSchema = z.object({
id: z.string()
.regex(/^\d+$/, 'ID debe ser un número')
.transform(val => parseInt(val, 10))
});
export const PaginationSchema = z.object({
page: z.string()
.optional()
.default('1')
.transform(val => parseInt(val, 10))
.refine(val => val > 0, 'Page debe ser mayor que 0'),
limit: z.string()
.optional()
.default('10')
.transform(val => parseInt(val, 10))
.refine(val => val > 0 && val <= 100, 'Limit debe estar entre 1 y 100')
});
// Tipos derivados (como Rails type definitions)
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
export type UserParams = z.infer<typeof UserParamsSchema>;
export type PaginationParams = z.infer<typeof PaginationSchema>;
Middleware de validación
// src/middleware/validation.middleware.ts (como Rails before_action con strong params)
import { Request, Response, NextFunction } from 'express';
import { z, ZodSchema } from 'zod';
export interface ValidatedRequest<TBody = any, TParams = any, TQuery = any> extends Request {
validatedBody?: TBody;
validatedParams?: TParams;
validatedQuery?: TQuery;
}
// Factory para crear middleware de validación
export function validateRequest<TBody, TParams, TQuery>(schemas: {
body?: ZodSchema<TBody>;
params?: ZodSchema<TParams>;
query?: ZodSchema<TQuery>;
}) {
return async (req: ValidatedRequest<TBody, TParams, TQuery>, res: Response, next: NextFunction) => {
try {
// Validar body
if (schemas.body) {
req.validatedBody = await schemas.body.parseAsync(req.body);
}
// Validar params
if (schemas.params) {
req.validatedParams = await schemas.params.parseAsync(req.params);
}
// Validar query
if (schemas.query) {
req.validatedQuery = await schemas.query.parseAsync(req.query);
}
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(422).json({
error: 'Validation failed',
details: error.errors.reduce((acc, err) => {
const path = err.path.join('.');
acc[path] = err.message;
return acc;
}, {} as Record<string, string>)
});
}
next(error);
}
};
}
// Helper para validación manual
export async function validateData<T>(schema: ZodSchema<T>, data: unknown): Promise<T> {
try {
return await schema.parseAsync(data);
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Validation failed: ${error.errors.map(e => e.message).join(', ')}`);
}
throw error;
}
}
Uso en controladores
// src/controllers/users.controller.ts (versión con validación)
import { Response, NextFunction } from 'express';
import { ValidatedRequest, validateRequest } from '../middleware/validation.middleware';
import { CreateUserSchema, UpdateUserSchema, UserParamsSchema, PaginationSchema } from '../schemas/user.schema';
import type { CreateUserInput, UpdateUserInput, UserParams, PaginationParams } from '../schemas/user.schema';
export class UsersController {
// Middleware de validación para cada acción
static validateCreate = validateRequest({ body: CreateUserSchema });
static validateUpdate = validateRequest({
body: UpdateUserSchema,
params: UserParamsSchema
});
static validateShow = validateRequest({ params: UserParamsSchema });
static validateIndex = validateRequest({ query: PaginationSchema });
static async index(
req: ValidatedRequest<never, never, PaginationParams>,
res: Response
) {
const { page, limit } = req.validatedQuery!; // Ya validado por middleware
// Simulamos query con paginación
await new Promise(resolve => setTimeout(resolve, 50));
const offset = (page - 1) * limit;
const paginatedUsers = users.slice(offset, offset + limit);
res.json({
users: paginatedUsers,
pagination: {
page,
limit,
total: users.length,
pages: Math.ceil(users.length / limit)
}
});
}
static async show(
req: ValidatedRequest<never, UserParams>,
res: Response
) {
const { id } = req.validatedParams!;
const user = users.find(u => u.id === id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ user });
}
static async create(
req: ValidatedRequest<CreateUserInput>,
res: Response
) {
const userData = req.validatedBody!; // Ya validado y tipado
// Simular validación de unicidad (como Rails validates :email, uniqueness: true)
const existingUser = users.find(u => u.email === userData.email);
if (existingUser) {
return res.status(422).json({
error: 'Validation failed',
details: { email: 'already exists' }
});
}
const newUser = {
id: users.length + 1,
...userData,
createdAt: new Date(),
updatedAt: new Date()
};
users.push(newUser);
res.status(201).json({ user: newUser });
}
static async update(
req: ValidatedRequest<UpdateUserInput, UserParams>,
res: Response
) {
const { id } = req.validatedParams!;
const updateData = req.validatedBody!;
const userIndex = users.findIndex(u => u.id === id);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
users[userIndex] = {
...users[userIndex],
...updateData,
updatedAt: new Date()
};
res.json({ user: users[userIndex] });
}
}
Rutas con validación
// src/routes/users.routes.ts
import { Router } from 'express';
import { UsersController } from '../controllers/users.controller';
const router = Router();
// Aplicar validación como middleware (como Rails before_action)
router.get('/',
UsersController.validateIndex,
UsersController.index
);
router.get('/:id',
UsersController.validateShow,
UsersController.show
);
router.post('/',
UsersController.validateCreate,
UsersController.create
);
router.put('/:id',
UsersController.validateUpdate,
UsersController.update
);
export { router as usersRouter };
💪 Ejercicios prácticos
🔥 Ejercicio 1: Middleware de métricas (30 min)
Objetivo: Crear un sistema de métricas como los que usarías en Rails con StatsD o New Relic.
Requerimientos:
- Contar requests por endpoint
- Medir tiempo de respuesta promedio
- Exponer métricas en
GET /metrics
- Persistir en memoria durante la sesión
// Starter code en src/middleware/metrics.middleware.ts
interface MetricData {
count: number;
totalTime: number;
avgTime: number;
}
export class MetricsCollector {
private metrics: Map<string, MetricData> = new Map();
// TODO: Implementar colección de métricas
// TODO: Implementar middleware para Express
// TODO: Implementar endpoint /metrics
}
💡 Solución
// src/middleware/metrics.middleware.ts
import { Request, Response, NextFunction } from 'express';
interface MetricData {
count: number;
totalTime: number;
avgTime: number;
lastAccess: Date;
}
export class MetricsCollector {
private static instance: MetricsCollector;
private metrics: Map = new Map();
static getInstance(): MetricsCollector {
if (!MetricsCollector.instance) {
MetricsCollector.instance = new MetricsCollector();
}
return MetricsCollector.instance;
}
recordRequest(endpoint: string, responseTime: number) {
const existing = this.metrics.get(endpoint) || {
count: 0,
totalTime: 0,
avgTime: 0,
lastAccess: new Date()
};
existing.count += 1;
existing.totalTime += responseTime;
existing.avgTime = existing.totalTime / existing.count;
existing.lastAccess = new Date();
this.metrics.set(endpoint, existing);
}
getMetrics(): Record {
const result: Record = {};
for (const [endpoint, data] of this.metrics) {
result[endpoint] = { ...data };
}
return result;
}
middleware() {
return (req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
const endpoint = `${req.method} ${req.route?.path || req.path}`;
res.on('finish', () => {
const responseTime = Date.now() - start;
this.recordRequest(endpoint, responseTime);
});
next();
};
}
metricsEndpoint() {
return (req: Request, res: Response) => {
const metrics = this.getMetrics();
const summary = {
totalRequests: Object.values(metrics).reduce((sum, m) => sum + m.count, 0),
endpoints: metrics,
uptime: process.uptime(),
memory: process.memoryUsage(),
timestamp: new Date().toISOString()
};
res.json(summary);
};
}
}
// Uso en express-server.ts:
const metricsCollector = MetricsCollector.getInstance();
app.use(metricsCollector.middleware());
app.get('/metrics', metricsCollector.metricsEndpoint());
🔥 Ejercicio 2: Sistema de retry con backoff (45 min)
Objetivo: Implementar una función de retry con backoff exponencial, como las que usas para jobs en Sidekiq.
Requerimientos:
- Retry automático en caso de fallo
- Backoff exponencial (1s, 2s, 4s, 8s...)
- Configuración de número máximo de intentos
- Logs detallados de cada intento
// Starter code
interface RetryOptions {
maxAttempts: number;
initialDelayMs: number;
backoffMultiplier: number;
maxDelayMs: number;
}
export async function withRetry<T>(
operation: () => Promise<T>,
options: RetryOptions
): Promise<T> {
// TODO: Implementar lógica de retry
}
💡 Solución
// src/utils/retry.ts
interface RetryOptions {
maxAttempts: number;
initialDelayMs: number;
backoffMultiplier: number;
maxDelayMs: number;
onRetry?: (attempt: number, error: Error) => void;
}
const defaultOptions: RetryOptions = {
maxAttempts: 3,
initialDelayMs: 1000,
backoffMultiplier: 2,
maxDelayMs: 30000
};
export async function withRetry(
operation: () => Promise,
options: Partial = {}
): Promise {
const config = { ...defaultOptions, ...options };
let lastError: Error;
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
try {
const result = await operation();
if (attempt > 1) {
console.log(`✅ Operation succeeded on attempt ${attempt}`);
}
return result;
} catch (error) {
lastError = error as Error;
if (attempt === config.maxAttempts) {
console.error(`❌ Operation failed after ${config.maxAttempts} attempts`);
break;
}
const delay = Math.min(
config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt - 1),
config.maxDelayMs
);
console.warn(`⚠️ Attempt ${attempt} failed: ${lastError.message}. Retrying in ${delay}ms...`);
config.onRetry?.(attempt, lastError);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError!;
}
// Ejemplo de uso con una API externa
export async function fetchWithRetry(url: string): Promise {
return withRetry(
async () => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
},
{
maxAttempts: 5,
initialDelayMs: 500,
onRetry: (attempt, error) => {
console.log(`Retry attempt ${attempt} for ${url}: ${error.message}`);
}
}
);
}
// Test del retry
export async function testRetryFunction() {
let attempts = 0;
try {
const result = await withRetry(async () => {
attempts++;
if (attempts < 3) {
throw new Error(`Simulated failure on attempt ${attempts}`);
}
return `Success on attempt ${attempts}`;
});
console.log('Result:', result);
} catch (error) {
console.error('Final error:', error.message);
}
}
🔥 Ejercicio 3: API de Posts con validación completa (60 min)
Objetivo: Crear un CRUD completo para Posts con todas las mejores prácticas.
Requerimientos:
- Schema de validación con Zod
- Relación Posts → Comments (simulada)
- Paginación, filtrado y ordenamiento
- Tests completos
- Manejo de errores
// Estructuras esperadas:
interface Post {
id: number;
title: string;
body: string;
authorId: number;
status: 'draft' | 'published' | 'archived';
tags: string[];
createdAt: Date;
updatedAt: Date;
}
interface Comment {
id: number;
postId: number;
author: string;
body: string;
createdAt: Date;
}
// Endpoints a implementar:
// GET /posts?page=1&limit=10&status=published&sort=createdAt:desc
// GET /posts/:id
// POST /posts
// PUT /posts/:id
// DELETE /posts/:id
// GET /posts/:id/comments
// POST /posts/:id/comments
💡 Solución parcial (estructura base)
// src/schemas/post.schema.ts
import { z } from 'zod';
export const CreatePostSchema = z.object({
title: z.string()
.min(5, 'Título muy corto')
.max(200, 'Título muy largo'),
body: z.string()
.min(50, 'Contenido muy corto')
.max(10000, 'Contenido muy largo'),
authorId: z.number()
.int('Author ID debe ser entero')
.positive('Author ID debe ser positivo'),
status: z.enum(['draft', 'published', 'archived'])
.default('draft'),
tags: z.array(z.string())
.min(1, 'Al menos un tag es requerido')
.max(10, 'Máximo 10 tags')
});
export const PostQuerySchema = z.object({
page: z.string().optional().default('1').transform(Number),
limit: z.string().optional().default('10').transform(Number),
status: z.enum(['draft', 'published', 'archived']).optional(),
sort: z.string()
.regex(/^(title|createdAt|updatedAt):(asc|desc)$/)
.default('createdAt:desc')
});
export type CreatePostInput = z.infer;
export type PostQuery = z.infer;
🔥 Ejercicio 4: Rate limiting personalizado (45 min)
Objetivo: Implementar rate limiting sin librerías externas, como lo harías en Rails con Rack::Attack.
Requerimientos:
- Diferentes límites por endpoint
- Límites por IP y por API key
- Ventana deslizante (sliding window)
- Headers informativos (
X-RateLimit-*
)
💡 Solución
// src/middleware/rate-limiter.ts
interface RateLimitRule {
windowMs: number;
maxRequests: number;
identifier: (req: Request) => string;
}
interface RequestRecord {
timestamps: number[];
blocked: boolean;
}
export class RateLimiter {
private requests: Map = new Map();
constructor(private rules: Record) {}
middleware(ruleKey: string) {
return (req: Request, res: Response, next: NextFunction) => {
const rule = this.rules[ruleKey];
if (!rule) return next();
const identifier = `${ruleKey}:${rule.identifier(req)}`;
const now = Date.now();
// Obtener registro o crear uno nuevo
const record = this.requests.get(identifier) || { timestamps: [], blocked: false };
// Limpiar timestamps antiguos (sliding window)
record.timestamps = record.timestamps.filter(
timestamp => now - timestamp < rule.windowMs
);
// Verificar límite
if (record.timestamps.length >= rule.maxRequests) {
record.blocked = true;
// Headers informativos
res.setHeader('X-RateLimit-Limit', rule.maxRequests);
res.setHeader('X-RateLimit-Remaining', 0);
res.setHeader('X-RateLimit-Reset', new Date(now + rule.windowMs));
return res.status(429).json({
error: 'Rate limit exceeded',
retryAfter: Math.ceil(rule.windowMs / 1000)
});
}
// Registrar request actual
record.timestamps.push(now);
record.blocked = false;
this.requests.set(identifier, record);
// Headers informativos
res.setHeader('X-RateLimit-Limit', rule.maxRequests);
res.setHeader('X-RateLimit-Remaining', rule.maxRequests - record.timestamps.length);
next();
};
}
}
// Configuración de reglas
const rateLimiter = new RateLimiter({
'api-general': {
windowMs: 15 * 60 * 1000, // 15 minutos
maxRequests: 100,
identifier: (req) => req.ip
},
'api-create': {
windowMs: 60 * 1000, // 1 minuto
maxRequests: 5,
identifier: (req) => req.ip
},
'api-key': {
windowMs: 60 * 1000,
maxRequests: 1000,
identifier: (req) => req.headers['x-api-key'] as string || req.ip
}
});
// Uso en rutas:
app.use('/api', rateLimiter.middleware('api-general'));
app.post('/api/users', rateLimiter.middleware('api-create'), UsersController.create);
🔥 Ejercicio 5: Logger estructurado (30 min)
Objetivo: Crear un sistema de logging estructurado como los que usas en Rails con semantic_logger.
Requerimientos:
- Diferentes niveles (debug, info, warn, error)
- Contexto estructurado (JSON)
- Correlation ID tracking
- Formato configurable
💡 Solución
// src/utils/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface LogContext {
requestId?: string;
userId?: number;
action?: string;
duration?: number;
[key: string]: any;
}
export class StructuredLogger {
private static instance: StructuredLogger;
static getInstance(): StructuredLogger {
if (!StructuredLogger.instance) {
StructuredLogger.instance = new StructuredLogger();
}
return StructuredLogger.instance;
}
private log(level: LogLevel, message: string, context: LogContext = {}) {
const logEntry = {
timestamp: new Date().toISOString(),
level,
message,
...context,
pid: process.pid,
hostname: require('os').hostname()
};
const output = JSON.stringify(logEntry);
switch (level) {
case 'debug':
console.debug(output);
break;
case 'info':
console.info(output);
break;
case 'warn':
console.warn(output);
break;
case 'error':
console.error(output);
break;
}
}
debug(message: string, context?: LogContext) {
this.log('debug', message, context);
}
info(message: string, context?: LogContext) {
this.log('info', message, context);
}
warn(message: string, context?: LogContext) {
this.log('warn', message, context);
}
error(message: string, context?: LogContext) {
this.log('error', message, context);
}
}
export const logger = StructuredLogger.getInstance();
// Middleware para logging de requests
export function loggingMiddleware(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
const requestId = req.headers['x-request-id'] as string;
logger.info('Request started', {
requestId,
method: req.method,
url: req.url,
userAgent: req.headers['user-agent'],
ip: req.ip
});
res.on('finish', () => {
const duration = Date.now() - start;
logger.info('Request completed', {
requestId,
method: req.method,
url: req.url,
statusCode: res.statusCode,
duration
});
});
next();
}
✅ Checklist de salida de la Semana 1
🎯 Conceptos fundamentales
- [ ] Entiendo el Event Loop: Sé por qué
await
no bloquea y cuándo usarPromise.all
- [ ] Diferencia threading vs event-driven: Puedo explicar por qué Node es eficiente en I/O pero vulnerable a CPU blocking
- [ ] TypeScript estructural: Uso interfaces, tipos utilitarios y type guards correctamente
- [ ] Async/await vs Promises: Manejo correctamente la asincronía sin callback hell
🛠️ Habilidades técnicas
- [ ] HTTP server desde cero: Puedo crear un servidor básico sin frameworks
- [ ] Express middleware stack: Entiendo el orden de middleware y cómo crear los míos
- [ ] Validación de datos: Implemento validación robusta con Zod (preparación para NestJS DTOs)
- [ ] Testing con Jest: Escribo tests unitarios y de integración efectivos
🏗️ Arquitectura y patrones
- [ ] Separación de responsabilidades: Controllers delgados, lógica en services
- [ ] Manejo de errores: Sistema consistente de error handling y logging
- [ ] Métricas y observabilidad: Implemento logging estructurado y colección de métricas
- [ ] Rate limiting: Protejo APIs con límites configurables
🚀 Preparación para NestJS
- [ ] Mindset de inyección de dependencias: Entiendo cómo las dependencias se pasan explícitamente
- [ ] Decoradores mentalmente: Aunque no los uso aún, entiendo el patrón de metadatos
- [ ] Estructura modular: Organizo código en módulos reutilizables
- [ ] Testing como ciudadano de primera clase: Tests son parte integral, no afterthought
🔧 Desarrollo práctico
- [ ] Setup de proyecto Node: Configuro TypeScript, ESLint, Prettier y Jest desde cero
- [ ] Debugging efectivo: Uso console, debugger y herramientas de Node para troubleshooting
- [ ] NPM ecosystem: Entiendo package.json, scripts, y gestión de dependencias
- [ ] Performance awareness: Identifico bottlenecks y optimizo operaciones I/O
🎯 Reflexiones finales y próximos pasos
Lo que lograste esta semana
Cambio mental clave: Pasaste de pensar en "threads que pueden bloquear" (Rails) a "un loop que debe fluir" (Node). Este cambio mental es fundamental para todo lo que viene.
Foundation sólida: Ahora tienes los conceptos base que NestJS abstraerá con decoradores y inyección de dependencias. Cuando veas @Injectable()
o @Controller()
, entenderás qué está pasando por debajo.
Herramientas fundamentales: TypeScript, Jest, Express y Zod son el toolkit que usarás constantemente en NestJS. Ya no serán "nueva sintaxis", serán herramientas familiares.
Preparación para Semana 2
En la semana 2 verás cómo NestJS toma todo lo que hiciste manualmente esta semana y lo hace declarativo:
- Tu
validateRequest
middleware →@Body()
+ DTO + ValidationPipe - Tu error handling manual → Exception Filters automáticos
- Tu logging middleware → Interceptors configurables
- Tu rate limiting → Guards reutilizables
- Tu estructura manual → Módulos con inyección de dependencias
🏆 Validation final
¿Puedes hacer esto en 10 minutos?
- Crear un nuevo proyecto Node + TypeScript
- Configurar un endpoint
POST /api/users
con validación de email y password - Escribir un test que valide tanto casos exitosos como errores 422
- Añadir middleware de logging que muestre duración de requests
Si puedes hacerlo cómodamente, estás listo para NestJS. Si no, repasa las secciones donde tengas dudas.
📚 Recursos de profundización
Event Loop y asincronía
TypeScript avanzado
- TypeScript Handbook
- Type Challenges - ejercicios prácticos
Testing en Node
Express profundo
Conclusión: Esta semana construiste el foundation mental y técnico necesario para aprovechar al máximo NestJS. Ya no será "sintaxis nueva encima de conceptos nuevos", sino "sintaxis nueva encima de conceptos que dominas". ¡Esa diferencia lo cambia todo!
Próxima semana: Toma todo lo que hiciste manualmente y dale superpoderes con decoradores, inyección de dependencias y la arquitectura modular de NestJS.
Top comments (0)