Awesome-omni-skill express-typescript-api-best-practices
Professional-grade REST API architecture with Express.js and TypeScript following SOLID principles, layered architecture, transaction management, JWT authentication with role-based authorization (RBAC), input validation with Zod, OpenAPI/Swagger documentation, standardized response format, and production-ready patterns. Use when building or refactoring REST APIs with Express + TypeScript that require enterprise-level code quality, maintainability, scalability, and security.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/express-typescript-api-best-practices" ~/.claude/skills/diegosouzapw-awesome-omni-skill-express-typescript-api-best-practices && rm -rf "$T"
skills/development/express-typescript-api-best-practices/SKILL.mdExpress + TypeScript REST API Best Practices
Professional skill for building production-ready REST APIs with Express.js and TypeScript, following SOLID principles and enterprise-grade architectural patterns.
Core Principles
1. Layered Architecture (Separation of Concerns)
Follow strict layer separation to achieve Single Responsibility Principle (SRP) and Dependency Inversion Principle (DIP):
Routes → Controllers → Services → Models → Database
Layer Responsibilities:
- Routes (
): Define HTTP endpoints, apply middlewaresrc/v1/routes/ - Controllers (
): Handle HTTP request/response, format datasrc/controllers/ - Services (
): Contain business logic, orchestrate transactionssrc/services/ - Models (
): Define data structures (ORM models)src/models/ - Middlewares (
): Cross-cutting concerns (auth, validation)src/middlewares/
Implementation Rules:
// ❌ BAD: Business logic in controller export const createUser = async (req: Request, res: Response) => { const hashedPassword = await argon2.hash(req.body.password); const user = await User.create({ ...req.body, password: hashedPassword }); res.json(user); }; // ✅ GOOD: Controller delegates to service export const createUser = async (req: Request, res: Response) => { try { const user = await crearUsuario(req.body); res.status(201).json({ status: "ok", message: "Usuario creado correctamente", data: user }); } catch (error: any) { res.status(500).json({ status: "error", message: "Error al crear usuario", error: error.message }); } };
2. Standardized API Response Format
ALL endpoints MUST return consistent response structure:
// Success response { status: "ok", message: string, data?: any // Optional } // Error response { status: "error", message: string, error?: string, // Detailed error code?: string, // Error code (e.g., "TOKEN_INVALID") detalles?: object // Additional context }
HTTP Status Codes:
- Success (GET, PUT, DELETE)200
- Resource created (POST)201
- Validation error400
- Unauthorized (missing/invalid token)401
- Forbidden (insufficient permissions)403
- Resource not found404
- Server error500
3. Transaction Management Pattern
For operations involving multiple database changes, ALWAYS use transactions:
export const crearDetalleVenta = async (detalleVentaData: any[]) => { const transaction = await sequelize.transaction(); try { // 1. Validate input if (!Array.isArray(detalleVentaData) || detalleVentaData.length === 0) { throw new Error('detalleVentaData debe ser un array no vacío'); } // 2. Batch load related data (optimization) const productoIds = [...new Set(detalleVentaData.map(i => i.producto_id))]; const productos = await Producto.findAll({ where: { id: productoIds }, transaction }); const productoMap = new Map(productos.map(p => [p.id, p])); // 3. Process each item with business rules for (const item of detalleVentaData) { const producto = productoMap.get(item.producto_id); if (!producto) throw new Error(`Producto ${item.producto_id} no encontrado`); // Auto-fill fields item.precio_unitario = item.precio_unitario ?? Number(producto.precio_minorista); item.sub_total = item.sub_total ?? parseFloat((item.precio_unitario * item.cantidad).toFixed(2)); // Create related records (stock movement) const movimiento = await crearMovimiento({ tipo: "salida", producto_id: item.producto_id, almacen_id: item.almacen_id, cantidad: item.cantidad, precio_unitario: item.precio_unitario, descripcion: `Salida por venta ID: ${item.venta_id}` }, transaction); item.movimiento_id = movimiento.id; } // 4. Bulk insert (performance) const detalles = await DetalleVenta.bulkCreate(detalleVentaData, { transaction }); // 5. Update parent record with locking const venta = await Venta.findOne({ where: { id: detalleVentaData[0].venta_id }, transaction, lock: transaction.LOCK.UPDATE // Prevent race conditions }); if (!venta) throw new Error('Venta no encontrada'); const totalSubtotales = detalleVentaData.reduce((sum, d) => sum + d.sub_total, 0); if (venta.total === 0 || venta.total < totalSubtotales) { venta.total = totalSubtotales; await venta.save({ transaction }); } await transaction.commit(); return detalles; } catch (error) { await transaction.rollback(); console.error('Error en transacción:', error); throw error; } };
Transaction Best Practices:
- Pass transaction to ALL database operations within the scope
- Use optimistic locking (
) for concurrent updateslock: transaction.LOCK.UPDATE - Batch load related data BEFORE the loop (N+1 query prevention)
- Always rollback on error, commit on success
- Validate ALL input at the beginning
4. JWT Authentication & Role-Based Authorization (RBAC)
Authentication Middleware (
):autenticarToken
export const autenticarToken = (req: AuthRequest, res: Response, next: NextFunction): void => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN if (!token) { res.status(401).json({ status: "error", message: "Acceso no autorizado", error: "No se proporcionó un token de autenticación", code: "TOKEN_NOT_PROVIDED" }); return; } try { const payload = verificarToken(token); req.usuario = payload; // Attach user to request next(); } catch (error) { res.status(403).json({ status: "error", message: "Token inválido", error: "El token ha expirado o es inválido", code: "TOKEN_INVALID" }); } };
Authorization Middleware (
):verificarRol
export const verificarRol = (...rolesPermitidos: string[]) => { return async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => { if (!req.usuario) { res.status(401).json({ status: "error", message: "Autenticación requerida", code: "AUTHENTICATION_REQUIRED" }); return; } // Cache roles in request to avoid multiple DB queries if (!req.usuario.roles) { const rolesAsignados = await UsuarioRol.findAll({ where: { usuario_id: req.usuario.id }, include: [{ model: Rol, as: "rol" }] }); req.usuario.roles = rolesAsignados.map((ur: any) => ur.rol.nombre); } const tienePermiso = (req.usuario.roles || []).some(rol => rolesPermitidos.includes(rol) ); if (!tienePermiso) { res.status(403).json({ status: "error", message: "Permisos insuficientes", error: `Requiere rol: ${formatearRoles(rolesPermitidos)}`, code: "INSUFFICIENT_PERMISSIONS", detalles: { rolesRequeridos: rolesPermitidos, usuario: req.usuario.username } }); return; } next(); }; };
Usage in Routes:
// Apply globally to all routes app.use("/v1/usuarios", autenticarToken, usuariosRouter); // Apply per-route with role checking router.post("/", verificarRol("admin"), createUsuario); router.get("/", verificarRol("admin", "gerente"), getUsuarios);
Many-to-Many Role System:
- Users can have multiple roles (flexibility)
- Roles cached in request object (performance)
- Database-driven (no hardcoded roles)
5. Input Validation with Zod
Define schemas in
src/schemas/:
import { z } from "zod"; export const createUsuarioSchema = z.object({ body: z.object({ username: z.string() .min(3, "Username debe tener al menos 3 caracteres") .max(50, "Username no puede exceder 50 caracteres"), email: z.string() .email("Email inválido") .max(100), password: z.string() .min(8, "Contraseña debe tener al menos 8 caracteres") .max(100), rol_id: z.number().int().positive().optional(), mustChangePassword: z.boolean().optional() }), params: z.object({}), query: z.object({}) }); export type CreateUsuarioInput = z.infer<typeof createUsuarioSchema>;
Validation Middleware:
const validate = (schema: ZodObject<any, any>) => { return async (req: Request, res: Response, next: NextFunction): Promise<void> => { try { const result = await schema.safeParseAsync({ body: req.body, query: req.query, params: req.params }); if (!result.success) { const formattedErrors = result.error.issues.map(error => ({ field: error.path[1] || error.path[0], message: error.message })); res.status(400).json({ status: "error", message: "Error de validación", errors: formattedErrors }); return; } // Replace with validated data if (result.data.body) req.body = result.data.body; if (result.data.query) req.query = result.data.query as any; if (result.data.params) req.params = result.data.params as any; next(); } catch (error) { res.status(500).json({ status: "error", message: "Error interno en validación" }); } }; };
Usage:
router.post("/", validate(createUsuarioSchema), createUsuario);
6. Dynamic Data Inclusion Pattern
Support flexible data loading via query parameters:
// Controller: Parse query params function construirInclusions(include: string) { return String(include || "") .split(",") .map(s => s.trim()) .filter(Boolean); } export const getUsuarios = async (req: Request, res: Response) => { const includeList = construirInclusions(req.query.include as string); const usuarios = await obtenerUsuarios({ include: includeList }); res.json({ status: "ok", data: usuarios }); }; // Service: Build Sequelize includes type Opts = { include?: string[]; } const construirInclusions = (opts: Opts = {}): any[] => { const include: any[] = []; if (opts.include?.includes("roles")) { include.push({ model: Rol, as: "roles", through: { attributes: [] } // Hide join table }); } if (opts.include?.includes("almacen")) { include.push({ model: Almacen, as: "almacen" }); } return include; }; export const obtenerUsuarios = async (opts: Opts = {}) => { const include = construirInclusions(opts); return await Usuario.findAll({ include }); };
Usage:
GET /v1/usuarios?include=roles GET /v1/detalle_ventas?include=producto,almacen
7. OpenAPI/Swagger Documentation
Configure comprehensive API documentation:
// src/config/swagger.ts const swaggerDefinition = { openapi: '3.0.0', info: { title: 'API Sistema', version: '1.0.0', description: 'REST API con autenticación JWT y RBAC' }, servers: [ { url: `http://${SERVER_HOST}:${SERVER_PORT}`, description: 'Desarrollo' } ], components: { securitySchemes: { BearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } }, schemas: { SuccessResponse: { type: 'object', properties: { status: { type: 'string', enum: ['ok'] }, message: { type: 'string' }, data: { type: 'object' } } }, ErrorResponse: { type: 'object', properties: { status: { type: 'string', enum: ['error'] }, message: { type: 'string' }, error: { type: 'string' }, code: { type: 'string' } } } } }, paths: { '/v1/usuarios': { get: { tags: ['Usuarios'], security: [{ BearerAuth: [] }], responses: { 200: { description: 'Success' }, 401: { $ref: '#/components/responses/UnauthorizedError' } } } } } };
Mount in Express:
import swaggerUi from "swagger-ui-express"; import { swaggerSpec } from "./config/swagger"; app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec, { customSiteTitle: "API Documentation", customCss: '.swagger-ui .topbar { display: none }' }));
8. Security Best Practices
Password Hashing (Argon2):
import argon2 from "argon2"; // Hashing const hashedPassword = await argon2.hash(password); // Verification const isValid = await argon2.verify(hashedPassword, password);
Environment Variables:
// src/config/config.ts import dotenv from "dotenv"; dotenv.config(); export const SERVER_HOST = process.env.SERVER_HOST || "localhost"; export const SERVER_PORT = Number(process.env.SERVER_PORT) || 3000; export const DB_HOST = process.env.DB_HOST || "localhost"; export const DB_USER = process.env.DB_USER!; export const DB_PASS = process.env.DB_PASS!; export const DB_NAME = process.env.DB_NAME!;
JWT Configuration:
// src/config/jwt.ts import jwt from "jsonwebtoken"; const SECRET_KEY = process.env.JWT_SECRET || "your-secret-key"; export const generarToken = (payload: any): string => { return jwt.sign(payload, SECRET_KEY, { expiresIn: "1h" }); }; export const verificarToken = (token: string): any => { return jwt.verify(token, SECRET_KEY); };
9. Project Structure
src/ ├── config/ │ ├── db.ts # Database connection │ ├── config.ts # Environment variables │ ├── jwt.ts # JWT utilities │ └── swagger.ts # OpenAPI definition ├── models/ │ ├── usuarios/ │ │ ├── usuarios.ts │ │ ├── roles.ts │ │ ├── usuarios_roles.ts │ │ └── associations.ts # Model relationships │ ├── productos/ │ └── ventas/ ├── controllers/ │ ├── usuarios/ │ │ └── usuarios.controller.ts │ └── ventas/ ├── services/ │ ├── usuarios/ │ │ ├── usuarios.service.ts │ │ └── roles.service.ts │ └── ventas/ ├── middlewares/ │ ├── auth.middleware.ts # autenticarToken, verificarRol │ └── validateResource.ts # Zod validation ├── schemas/ │ ├── usuarios.schema.ts │ └── ventas.schema.ts ├── v1/ │ └── routes/ │ ├── usuarios/ │ │ └── usuarios.route.ts │ └── ventas/ ├── scripts/ │ └── seed-admin.ts # Database seeding └── index.ts # App entry point
10. Database Patterns
Model Associations (Sequelize):
// src/models/usuarios/associations.ts import Usuario from "./usuarios"; import Rol from "./roles"; import UsuarioRol from "./usuarios_roles"; // Many-to-Many Usuario.belongsToMany(Rol, { through: UsuarioRol, foreignKey: "usuario_id", otherKey: "rol_id", as: "roles" }); Rol.belongsToMany(Usuario, { through: UsuarioRol, foreignKey: "rol_id", otherKey: "usuario_id", as: "usuarios" }); // One-to-Many for join table UsuarioRol.belongsTo(Usuario, { foreignKey: "usuario_id", as: "usuario" }); UsuarioRol.belongsTo(Rol, { foreignKey: "rol_id", as: "rol" });
Database Initialization:
// src/index.ts app.listen(PORT, HOST, async () => { try { await sequelize.authenticate(); console.log("Database connected"); // sync({ force: false }) preserves data // sync({ force: true }) drops and recreates (DANGER!) await sequelize.sync({ force: false }); console.log("Models synchronized"); } catch (error) { console.error("Database connection failed:", error); } });
SOLID Principles Application
S - Single Responsibility Principle
- Each layer has ONE responsibility
- Controllers: HTTP handling
- Services: Business logic
- Models: Data structure
O - Open/Closed Principle
- Middleware composition allows extension without modification
- Dynamic includes support new relations without changing core logic
L - Liskov Substitution Principle
- Consistent response format allows interchangeable endpoints
- Middleware can be swapped without breaking the chain
I - Interface Segregation Principle
- Zod schemas define minimal required fields
- Optional
parameter avoids forcing unnecessary datainclude
D - Dependency Inversion Principle
- Controllers depend on service abstractions, not concrete implementations
- Services use ORM models (abstraction) rather than direct SQL
Quick Start Checklist
When creating a new REST API endpoint:
- ✅ Define Zod schema in
src/schemas/ - ✅ Create service function in
with business logicsrc/services/ - ✅ Use transactions for multi-step operations
- ✅ Create controller in
that calls servicesrc/controllers/ - ✅ Return standardized response format
- ✅ Define route in
src/v1/routes/ - ✅ Apply
middleware if protectedautenticarToken - ✅ Apply
middleware if role-restrictedverificarRol - ✅ Apply
middleware for input validationvalidate(schema) - ✅ Document in Swagger (
)src/config/swagger.ts
Common Patterns
For detailed implementations and code examples, see:
- Architecture Deep Dive - Detailed layer explanations
- Transaction Patterns - Complex transaction scenarios
- Authentication & RBAC - Complete auth implementation
- Validation Strategies - Zod patterns and custom validators
- Error Handling - Comprehensive error management
- Testing Strategies - Unit and integration test patterns
- Performance Optimization - Query optimization, caching
Anti-Patterns to Avoid
❌ DON'T:
- Put business logic in controllers
- Make database calls from controllers
- Forget transactions for multi-step operations
- Hardcode role names in code (use database)
- Return raw error messages to clients
- Skip input validation
- Use
in productionsync({ force: true }) - Expose sensitive data in JWT payload
- Use
without pagination (for large datasets)findAll()
✅ DO:
- Keep controllers thin (delegate to services)
- Wrap related operations in transactions
- Validate all input with Zod
- Return standardized response format
- Hash passwords with Argon2
- Cache user roles to avoid repeated DB queries
- Use environment variables for configuration
- Document all endpoints in Swagger
Skill Version: 1.0.0 Author: Miller Marru (@MILLERMARRU) Contact: millermarru4@gmail.com Repository: https://github.com/MILLERMARRU/express-typescript-api-best-practices License: MIT Last Updated: 2026-02-05