| 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:
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);
};
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:
{
status: "ok",
message: string,
data?: any
}
{
status: "error",
message: string,
error?: string,
code?: string,
detalles?: object
}
HTTP Status Codes:
200 - Success (GET, PUT, DELETE)
201 - Resource created (POST)
400 - Validation error
401 - Unauthorized (missing/invalid token)
403 - Forbidden (insufficient permissions)
404 - Resource not found
500 - 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 {
if (!Array.isArray(detalleVentaData) || detalleVentaData.length === 0) {
throw new Error('detalleVentaData debe ser un array no vacío');
}
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]));
for (const item of detalleVentaData) {
const producto = productoMap.get(item.producto_id);
(!producto) ();
item. = item. ?? (producto.);
item. = item. ?? ((item. * item.).());
movimiento = ({
: ,
: item.,
: item.,
: item.,
: item.,
:
}, transaction);
item. = movimiento.;
}
detalles = .(detalleVentaData, { transaction });
venta = .({
: { : detalleVentaData[]. },
transaction,
: transaction..
});
(!venta) ();
totalSubtotales = detalleVentaData.( sum + d., );
(venta. === || venta. < totalSubtotales) {
venta. = totalSubtotales;
venta.({ transaction });
}
transaction.();
detalles;
} (error) {
transaction.();
.(, error);
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];
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;
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;
}
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..);
}
tienePermiso = (req.. || []).(
rolesPermitidos.(rol)
);
(!tienePermiso) {
res.().({
: ,
: ,
: ,
: ,
: {
: rolesPermitidos,
: req..
}
});
;
}
();
};
};
Usage in Routes:
app.use("/v1/usuarios", autenticarToken, usuariosRouter);
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;
}
(result..) req. = result..;
(result..) req. = result.. ;
(result..) req. = result.. ;
();
} (error) {
res.().({
: ,
:
});
}
};
};
Usage:
router.post("/", validate(createUsuarioSchema), createUsuario);
6. Dynamic Data Inclusion Pattern
Support flexible data loading via query parameters:
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 });
};
type Opts = {
include?: string[];
}
const construirInclusions = (opts: Opts = {}): any[] => {
const include: any[] = [];
(opts.?.()) {
include.({
: ,
: ,
: { : [] }
});
}
(opts.?.()) {
include.({ : , : });
}
include;
};
= () => {
include = (opts);
.({ include });
};
Usage:
GET /v1/usuarios?include=roles
GET /v1/detalle_ventas?include=producto,almacen
7. OpenAPI/Swagger Documentation
Configure comprehensive API documentation:
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'] },
: { : },
: { : },
: { : }
}
}
}
},
: {
: {
: {
: [],
: [{ : [] }],
: {
: { : },
: { : }
}
}
}
}
};
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";
const hashedPassword = await argon2.hash(password);
const isValid = await argon2.verify(hashedPassword, password);
Environment Variables:
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:
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):
import Usuario from "./usuarios";
import Rol from "./roles";
import UsuarioRol from "./usuarios_roles";
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"
});
UsuarioRol.belongsTo(Usuario, { foreignKey: "usuario_id", as: "usuario" });
UsuarioRol.belongsTo(Rol, { foreignKey: "rol_id", as: "rol" });
Database Initialization:
app.listen(PORT, HOST, async () => {
try {
await sequelize.authenticate();
console.log("Database connected");
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
include parameter 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
autenticarToken middleware if protected
- ✅ Apply
verificarRol middleware 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:
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