Desde cero hasta producción: autenticación, validación, manejo de errores y buenas prácticas para APIs modernas.
Estructura del proyecto
Una API bien estructurada es mantenible a largo plazo. Esta es la organización que uso en proyectos reales:
src/
routes/ → Definición de endpoints
controllers/ → Lógica de cada endpoint
services/ → Lógica de negocio
middleware/ → Auth, validación, errores
models/ → Esquemas de BD
lib/ → DB connection, config
types/ → Tipos TypeScript
La separación controller → service es clave: el controller maneja HTTP, el service maneja la lógica.
Setup inicial
// src/app.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { errorHandler } from './middleware/errorHandler';
import { router } from './routes';
const app = express();
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }));
app.use(express.json({ limit: '10kb' }));
app.use('/api/v1', router);
app.use(errorHandler); // Siempre al final
export { app };
Validación de requests
Usa zod para validar el body antes de que llegue al controller:
// src/middleware/validate.ts
import { z, ZodSchema } from 'zod';
import { Request, Response, NextFunction } from 'express';
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
}
req.body = result.data;
next();
};
}
// Uso en la ruta:
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2).max(50),
});
router.post('/users', validate(createUserSchema), userController.create);
Manejo de errores centralizado
Define clases de error propias y un handler global:
// src/lib/errors.ts
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number,
public code?: string
) {
super(message);
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
// src/middleware/errorHandler.ts
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
});
}
console.error('Unexpected error:', err);
res.status(500).json({ error: 'Internal server error' });
}
Ahora en cualquier controller puedes hacer throw new NotFoundError('User') y el handler lo captura.
Autenticación con JWT
// src/middleware/auth.ts
import jwt from 'jsonwebtoken';
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new AppError('No token provided', 401, 'UNAUTHORIZED');
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = payload;
next();
} catch {
throw new AppError('Invalid token', 401, 'INVALID_TOKEN');
}
}
Rate limiting
Protege endpoints sensibles:
import rateLimit from 'express-rate-limit';
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 10,
message: { error: 'Too many attempts, try again later' },
});
router.post('/auth/login', authLimiter, authController.login);
Respuestas consistentes
Define un formato fijo para todas las respuestas:
// src/lib/response.ts
export function success<T>(res: Response, data: T, status = 200) {
return res.status(status).json({ success: true, data });
}
export function paginated<T>(res: Response, data: T[], meta: PaginationMeta) {
return res.json({ success: true, data, meta });
}
Conclusión
Una API robusta no es solo código que funciona — es código que falla de forma predecible, valida en los bordes, y es legible para el siguiente dev. Los patrones de este artículo (validación con Zod, errores tipados, respuestas consistentes) son los que marcan la diferencia entre un prototipo y código de producción.