Express.js
Crear una aplicación Express moderna
Creación de una base Express moderna con configuración validada, separación entre app y servidor, startup y testing.
- Última actualización
- Actualizada
- Nivel
- Fundamentos
Express.js
Creación de una base Express moderna con configuración validada, separación entre app y servidor, startup y testing.
Crear una aplicación Express moderna no consiste únicamente en ejecutar express() y llamar listen. La base debe separar tres responsabilidades:
configuración
→ valida lo necesario para arrancar
aplicación Express
→ declara middleware, rutas y manejo HTTP
proceso servidor
→ conecta dependencias, abre el puerto y coordina shutdownEsta separación evita side effects al importar módulos, facilita pruebas de integración y permite controlar correctamente errores de startup y cierre.
El ejemplo tradicional coloca todo en un archivo:
const app = express();
app.get('/', handler);
app.listen(3000);Funciona para aprender routing, pero empieza a causar problemas cuando:
SIGTERM.La solución no es crear veinte capas desde el primer día. Es separar los efectos del proceso de la definición del pipeline.
Express 5 requiere Node.js 18 o superior. Para un proyecto nuevo utiliza una rama LTS de Node que continúe soportada y fija la expectativa en package.json:
{
"type": "module",
"engines": {
"node": ">=22"
}
}engines comunica compatibilidad, pero npm no siempre la hace cumplir por defecto. La versión real también debe fijarse en el entorno de desarrollo, CI, Docker y plataforma de despliegue.
Express está escrito en JavaScript y no incluye sus definiciones TypeScript. Las definiciones comunitarias se instalan por separado.
npm init -y
npm install express
npm install -D typescript @types/node @types/express tsxUna configuración razonable para compilar a JavaScript puede utilizar NodeNext:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}Las opciones concretas dependen de la versión de Node y de cómo se ejecute TypeScript. Lo importante es que desarrollo, type checking y producción no dependan de supuestos distintos.
src/
config.ts
app.ts
server.tsEsta estructura todavía no introduce controllers, repositories ni un contenedor de dependencias. Solo separa responsabilidades que ya existen.
Las variables de entorno llegan como strings opcionales. Leerlas directamente en cualquier archivo dispersa defaults y permite fallos tardíos.
// config.ts
import { z } from 'zod';
const environmentSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
HOST: z.string().default('0.0.0.0'),
PORT: z.coerce.number().int().min(1).max(65_535).default(3000),
DATABASE_URL: z.string().url(),
});
export type AppConfig = z.infer<typeof environmentSchema>;
export function loadConfig(environment: NodeJS.ProcessEnv): AppConfig {
return environmentSchema.parse(environment);
}El flujo es:
process.env.Un secreto vacío o una URL inválida no deben descubrirse cuando llegue la primera request.
// app.ts
import express, { type Express } from 'express';
export type AppDependencies = {
logger: {
info(data: unknown, message?: string): void;
error(data: unknown, message?: string): void;
};
};
export function createApp(dependencies: AppDependencies): Express {
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '100kb' }));
app.get('/health/live', (_request, response) => {
response.status(200).json({ status: 'ok' });
});
app.use((request, response) => {
dependencies.logger.info(
{ method: request.method, path: request.path },
'Route not found',
);
response.status(404).json({ code: 'ROUTE_NOT_FOUND' });
});
return app;
}createApp no abre puertos ni lee variables globales. Recibe dependencias explícitas y devuelve una aplicación configurada.
// server.ts
import { createServer } from 'node:http';
import { createApp } from './app.js';
import { loadConfig } from './config.js';
const config = loadConfig(process.env);
const logger = console;
const app = createApp({ logger });
const server = createServer(app);
server.listen(config.PORT, config.HOST, () => {
logger.info(
{ host: config.HOST, port: config.PORT },
'HTTP server started',
);
});
server.on('error', (error) => {
logger.error({ error }, 'HTTP server failed');
process.exitCode = 1;
});Crear el servidor HTTP explícitamente permite acceder a:
error, connection y clientError.Para una app pequeña, app.listen también es válido. En Express 5, el callback de app.listen puede recibir el error de escucha; aun así, un servidor explícito suele resultar más claro cuando la operación importa.
Una aplicación real puede necesitar dependencias antes de escuchar:
validar config
↓
crear logger
↓
abrir pool de PostgreSQL
↓
verificar dependencias críticas
↓
crear app
↓
abrir servidor HTTPNo siempre conviene bloquear el startup por cualquier dependencia. Una integración opcional podría degradarse; la base de datos principal quizá deba impedir readiness. La decisión depende del servicio.
Confundirlos produce tests frágiles y dificulta operación.
import request from 'supertest';
import { createApp } from './app.js';
const logger = {
info() {},
error() {},
};
test('GET /health/live returns 200', async () => {
const app = createApp({ logger });
await request(app)
.get('/health/live')
.expect(200)
.expect({ status: 'ok' });
});Supertest puede invocar la app como handler. El test no necesita conocer un puerto disponible ni cerrar un servidor global.
Scripts posibles:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"typecheck": "tsc --noEmit",
"build": "tsc",
"start": "node dist/server.js",
"test": "node --test"
}
}En desarrollo, tsx reinicia el proceso. En CI, typecheck detecta contratos incompatibles. En producción, se ejecuta JavaScript construido y probado.
Versiones recientes de Node pueden ejecutar una parte de TypeScript directamente eliminando tipos, pero esa capacidad depende de versión y no reemplaza el type checking. Una wiki durable debe enseñar la separación entre ejecutar y verificar tipos.
EADDRINUSE indica que otra aplicación escucha el puerto. No debe convertirse en un servidor parcialmente iniciado.
EACCES puede aparecer en puertos privilegiados o entornos restringidos.
Si el pool principal no puede abrirse, decide si el proceso debe terminar o iniciar como no-ready. No aceptes tráfico normal sin un contrato claro.
Debe fallar antes de construir la app. El log puede nombrar la variable, pero nunca imprimir secretos.
Dentro de contenedores, escuchar solo en 127.0.0.1 suele impedir que tráfico externo alcance el proceso. 0.0.0.0 escucha todas las interfaces IPv4 disponibles.
Esto no publica mágicamente el puerto ni sustituye firewall, red del contenedor o plataforma. Solo define la interfaz local de escucha.
Node posee timeouts del servidor que deben configurarse según proxies y workload:
server.requestTimeout = 30_000;
server.headersTimeout = 35_000;
server.keepAliveTimeout = 5_000;Los valores no son universales. El timeout del proxy debe coordinarse con el servidor y las dependencias. Un timeout demasiado alto retiene recursos; uno demasiado bajo interrumpe operaciones legítimas.
Abrirá sockets y ejecutará configuración global. Por eso las pruebas deben importar createApp, no el entry point.
El segundo falla. Captura el error y termina con una señal visible para el supervisor.
Puede registrar dependencias con valores inválidos y fallar de forma parcial.
El valor queda capturado demasiado pronto y complica tests. Pasa configuración explícita.
Crear carpetas vacías de controllers, services, entities y repositories no genera límites reales. Añade módulos cuando exista una responsabilidad.
Hace difícil probar y reutilizar la aplicación.
Watchers y loaders aumentan superficie y comportamiento. Producción debe ejecutar un artefacto reproducible.
Un proceso que no escucha pero permanece vivo puede parecer saludable al orquestador.
Los errores de configuración deben redactar valores sensibles.
Un script local o prototipo desechable puede vivir en un archivo. Separar app y servidor vale la pena cuando existe testing HTTP, configuración, dependencias o despliegue.
No copies una plantilla empresarial en un ejercicio de diez líneas; tampoco lleves el ejemplo de diez líneas a producción sin reconocer sus límites.
.ts.server.ts no debería importarse desde una prueba de rutas?createServer(app) frente a ocultar el servidor?0.0.0.0 suele utilizarse en contenedores?Request y response como objetos HTTP explica qué recibe cada handler, qué estado mantienen esos objetos y cuáles datos deben considerarse no confiables.