name: express-typescript-api-best-practices description: 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.
Express + 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 (
src/v1/routes/): Define HTTP endpoints, apply middleware - Controllers (
src/controllers/): Handle HTTP request/response, format data - Services (
src/services/): Contain business logic, orchestrate transactions - Models (
src/models/): Define data structures (ORM models) - Middlewares (
src/middlewares/): Cross-cutting concerns (auth, validation)
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:
200- Success (GET, PUT, DELETE)201- Resource created (POST)400- Validation error401- Unauthorized (missing/invalid token)403- Forbidden (insufficient permissions)404- Resource not found500- Server error
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 (
lock: transaction.LOCK.UPDATE) for concurrent updates - 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
includeparameter avoids forcing unnecessary data
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
src/services/with business logic - ✅ Use transactions for multi-step operations
- ✅ Create controller in
src/controllers/that calls service - ✅ Return standardized response format
- ✅ Define route in
src/v1/routes/ - ✅ Apply
autenticarTokenmiddleware if protected - ✅ Apply
verificarRolmiddleware if role-restricted - ✅ Apply
validate(schema)middleware for input validation - ✅ 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
sync({ force: true })in production - Expose sensitive data in JWT payload
- Use
findAll()without pagination (for large datasets)
✅ 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