Diseño de API en Node.js con capas: de controladores complejos a arquitectura limpia
Aprenda cómo refactorizar una API de Node.js en capas de controlador, servicio y acceso a datos para solucionar la lógica empresarial complicada, los errores inconsistentes y las dificultades de escalado.
Fuentes: I Built a Node.js API. Then I Realized I Was Doing It Wrong.
Lecciones prácticas sobre cómo estructurar, escalar y mantener un backend de Node.js adecuadamente, basadas en la creación de APIs para producción.
Cuando comienzas a escribir APIs en Node.js, el trabajo de backend puede parecer sorprendentemente sencillo.
Define una ruta.
Vincúlala a la base de datos.
Envía algo de JSON de vuelta.
Eso es todo.
Y durante un tiempo, ese enfoque realmente funciona.
Luego la aplicación comienza a crecer.
De repente te enfrentas a:
- Reglas de negocio incrustadas directamente en los controladores
- Llamadas a la base de datos dispersas en cada archivo de ruta
- Lógica de validación repetida
- Manejo de errores que varía de un endpoint a otro
- Archivos excesivamente grandes
- Código que se resiste a las pruebas automatizadas
- Lógica de autenticación entrelazada con la lógica del dominio
- Cambios pequeños que rompen funcionalidades que parecían no estar relacionadas
Técnicamente, la API no está fallando.
Algo más sutil está yendo mal:
Hacer cambios en ella se ha vuelto realmente doloroso.
Ese es el momento en que te das cuenta de que existe una diferencia real entre una API que simplemente funciona y otra que puede resistir un desarrollo continuo en el mundo real.
1. Mi arquitectura inicial de Node.js
Una API típica en las etapas iniciales podría verse así:
const express = require("express");
const app = express();
app.get("/users", async (req, res) => {
const users = await db.query("SELECT * FROM users"); res.json(users);
});app.post("/users", async (req, res) => {
const { name, email } = req.body; const user = await db.query(
"INSERT INTO users (name, email) VALUES ($1, $2)",
[name, email]
); res.json(user);
});app.listen(3000);
Ninguno de estos códigos está mal por sí solo.
Para un prototipo rápido, es un punto de partida completamente razonable.
Los problemas comienzan una vez que el proyecto empieza a escalar.
Imagina agregar capas de funcionalidades como:
- Autenticación
- Autorización
- Validación de entradas
- Verificación de correos electrónicos
- Procesamiento de pagos
- Gestión de cupones
- Gestión de pedidos
- Seguimiento de inventario
Imagínese ahora tener que incluir todos esos aspectos directamente en los controladores de rutas.
En ese caso ya no se cuenta con una verdadera arquitectura.
Lo que hay es un montón de código que simplemente se ejecuta.
2. El problema no era Node.js
Es un error fácil de cometer al principio.
El instinto es pensar:
"Mi base de código se está volviendo caótica simplemente porque la aplicación crece en tamaño."
Eso es solo parte de la historia.
La causa real era la falta de separación de responsabilidades.
Un único controlador se encargaba de demasiadas cosas.
Era responsable de:
- Analizar la solicitud recibida
- Validar los datos de entrada
- Verificar la autenticación
- Ejecutar la lógica de negocio
Eso son seis o siete tareas distintas amontonadas en un mismo lugar.
Cada vez que cambiaba un requisito, había que editar ese mismo archivo una y otra vez.
Repetidamente.
3. La arquitectura que uso ahora
Una estrategia más limpia consiste en dividir la aplicación en capas separadas.
Por ejemplo:
src/
│
├── controllers/
│ └── user.controller.ts
│
├── services/
│ └── user.service.ts
│
├── repositories/
│ └── user.repository.ts
│
├── dto/
│ └── create-user.dto.ts
│
├── middleware/
│ └── auth.middleware.ts
│
├── routes/
│ └── user.routes.ts
│
├── utils/
│ └── error-handler.ts
│
└── app.ts
El principio subyacente es sencillo:
Cada capa debe tener una responsabilidad clara.
4. Los controladores no deben contener lógica de negocio
La función de un controlador debe limitarse a los asuntos relacionados con HTTP.
Algo similar a esto:
export class UserController {
constructor(
private readonly userService: UserService
) {}
async createUser(req: Request, res: Response) {
try {
const user = await this.userService.createUser(req.body); return res.status(201).json({
success: true,
data: user,
});
} catch (error) {
return res.status(500).json({
success: false,
message: error.message,
});
}
}
}
El controlador no necesita saber:
- Dónde o cómo se almacena el registro del usuario
- Qué tecnología de base de datos se está utilizando
Nada de eso es de su incumbencia.
Su única responsabilidad es manejar la solicitud entrante y dar forma a la respuesta saliente.
5. Colocar la lógica de negocio en servicios
La capa de servicio es donde reside la lógica real de la aplicación.
export class UserService {
constructor(
private readonly userRepository: UserRepository
) {}
async createUser(data: CreateUserDto) {
const existingUser =
await this.userRepository.findByEmail(data.email); if (existingUser) {
throw new Error("User already exists");
} const hashedPassword = await hashPassword(
data.password
); return this.userRepository.create({
...data,
password: hashedPassword,
});
}
}
Esto hace que la lógica sea reutilizable.
Otra parte del sistema, por ejemplo, podría simplemente llamar:
userService.createUser(data);
sin necesidad de simular una solicitud HTTP en absoluto.
Esa reutilización es muy importante tanto para las pruebas como para la mantenibilidad a largo plazo.
6. El acceso a la base de datos merece su propia capa
La lógica de negocio no debería verse saturada por instrucciones SQL en bruto o llamadas ORM dispersas por todas partes.
Este es exactamente el problema que resuelven los repositorios.
export class UserRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({
where: {
email,
},
});
} async create(data: CreateUserInput) {
return prisma.user.create({
data,
});
}
}
Con esto en su lugar, la separación de responsabilidades se vuelve evidente.
Request
↓
Controller
↓
Service
↓
Repository
↓
Database
Cada capa solo conoce lo que estrictamente necesita saber, y nada más.
7. Por qué los DTO son más importantes de lo que uno podría esperar
Un error común al principio es pasar cuerpos de solicitud sin procesar directamente a la aplicación.
Algo como esto:
const user = await userService.createUser(req.body);
Eso es arriesgado.
¿Qué hay realmente dentro de req.body?
, ¿Puede estar seguro?
Un DTO establece un contrato claro y definido de lo que se espera.
class CreateUserDto {
name: string;
email: string;
password: string;
}
A partir de ahí, los datos recibidos se validan antes de que puedan acercarse siquiera a la lógica de la aplicación.
Por ejemplo, un payload como:
{
"name": "Mit",
"email": "mit@example.com",
"password": "secret123"
}
puede ser verificado contra reglas tales como:
- Campos obligatorios
- Formato de correo electrónico
- L longitud de la contraseña
- Tipos de datos
El principio fundamental aquí es sencillo:
Nunca confíes en la información proveniente del cliente.
El frontend no funciona como barrera de seguridad.
8. La consistencia en el manejo de errores es importante
Un problema relacionado en las APIs en etapas iniciales es la inconsistencia en la estructura de las respuestas.
Una ruta podría devolver:
{
"error": "User not found"
}
Otra responde con:
{
"message": "Not found"
}
Mientras que otra más parece así:
{
"success": false,
"data": null
}
Para cualquiera que construya un frontend para esta API, esa inconsistencia resulta agotadora.
Adoptar un enfoque centralizado para el manejo de errores hace que la API sea mucho más fácil de utilizar.
Por ejemplo:
{
"success": false,
"message": "User not found",
"code": "USER_NOT_FOUND"
}
La estructura específica elegida no importa demasiado.
Lo importante es que siempre sea la misma.
9. La autenticación no es autorización
Esta es una distinción que confunde a muchos desarrolladores al principio.
La autenticación responde a una pregunta:
¿Quién eres?
La autorización responde a otra diferente:
¿Qué se te permite hacer?
En términos de flujo, se parece más o menos a esto:
Login
↓
Authentication
↓
JWT / Session
↓
User identified
↓
Authorization
↓
Can this user delete the product?
Solo porque un usuario esté autenticado no significa que tenga permiso para realizar cualquier acción que desee.
Por eso precisamente las verificaciones de roles y permisos deben realizarse por separado de la lógica básica de inicio de sesión/autenticación.
10. Al final hay que pensar en la escalabilidad
Una vez que el código está razonablemente bien organizado, surge una nueva pregunta:
¿Qué sucede a medida que aumenta el tráfico?
Es en este punto donde elementos como Redis, colas de tareas e índices de base de datos comienzan a ser útiles.
Imagínese un punto de extremo que ejecuta constantemente una consulta a la base de datos costosa con cada solicitud.
En lugar de:
Request
↓
Database
↓
Response
el sistema podría pasar eventualmente a algo como:
Request
↓
Redis Cache
↓
Cache Hit → Response
│
└── Cache Miss
↓
Database
↓
Redis
↓
Response
Y para cualquier tarea que no debería bloquear la respuesta:
API
↓
Queue
↓
Worker
↓
Email / Notification / Processing
Esto evita que el trabajo lento y no crítico retrase la solicitud real.
Pero aquí está la advertencia que vale la pena repetir:
No recurra a Redis, colas, Kafka, microservicios o Kubernetes solo porque parezcan impresionantes.
La arquitectura existe para resolver un problema real.
No debería crear otros nuevos.
11. La lección más importante aquí
El verdadero cambio de mentalidad se resume en esto:
Deje de optimizar por la rapidez con la que se escribe el código.
Optimice en su lugar por la seguridad con la que se podrá cambiar más adelante.
Al comenzar un primer proyecto, parece eficiente meterlo todo en un único archivo.
Y al principio, de verdad lo es.
Pero escribir la versión inicial representa solo una pequeña fracción del trabajo total.
La mayor parte de lo que ocurre con una base de código llega después.
Con el tiempo, un proyecto necesitará:
- Agregar funcionalidades
- Corregir errores
- Adaptarse a requisitos cambiantes
- Sustituir bibliotecas
- Actualizar los esquemas de la base de datos
- Añadir mecanismos de autenticación
- Introducir sistemas de permisos
- Acelerar las consultas lentas
- Conectarse con servicios de terceros
Si la arquitectura convierte cada uno de estos pasos en un desafío, la velocidad inicial obtenida en realidad no fue una ventaja.
12. Un punto de partida mejor
Un backend fresco en Node.js hoy en día podría organizarse razonablemente de la siguiente manera:
src/
│
├── modules/
│ ├── users/
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ ├── user.dto.ts
│ │ └── user.routes.ts
│ │
│ ├── products/
│ │ ├── product.controller.ts
│ │ ├── product.service.ts
│ │ ├── product.repository.ts
│ │ └── product.dto.ts
│
├── middleware/
├── config/
├── database/
├── utils/
└── app.ts
En aplicaciones más grandes, organizar el código en torno a módulos o funcionalidades suele ser más eficaz que agrupar todo en carpetas compartidas enormes llenas de controladores y servicios no relacionados.
La estructura exacta de las carpetas no es lo importante.
Lo que cuenta es que la responsabilidad de cada componente permanezca clara.
13. Siete reglas que vale la pena seguir
En resumen, todo se reduce a siete reglas:
1. Mantén los controladores simples
Los controladores existen para manejar HTTP, y nada más.
2. Coloca la lógica de negocio en servicios
Los servicios son donde debe residir el comportamiento real de una aplicación.
3. Aísla las operaciones de base de datos
Evita que las consultas a la base de datos se extiendan a todos los rincones del código.
4. Valida la entrada externa
No asumas que req.body contiene lo esperado.
5. Estandariza los errores
Haga que las respuestas ante fallos sean predecibles y uniformes.
6. Agregue infraestructura solo cuando sea necesario
Redis, colas y microservicios son herramientas que se utilizan cuando está justificado hacerlo, no señales de mayor experiencia.
7. Diseñe para el cambio
Quienquiera que mantenga este código lo modificará con mucha más frecuencia de lo que lo escribirá desde cero.
Conclusión
Poner en marcha una API de Node.js no es difícil.
Mantenerla comprensible meses después de seguir trabajando en nuevas funcionalidades es un desafío mucho mayor.
Es fácil equiparar un buen desarrollo de backend con saber cómo conectar:
GET
POST
PUT
DELETE
Eso es realmente solo el punto de partida.
La verdadera habilidad radica en saber dónde debe residir cada parte de la lógica, cómo se comunican entre sí las diferentes partes del sistema y cómo evolucionarlo sin dañar todo lo demás en el proceso.
No todo proyecto necesita una arquitectura elaborada.
Lo que sí se necesita es una arquitectura proporcional a la complejidad del problema real.
Porque el objetivo nunca fue:
“Hacer que funcione.”
El verdadero objetivo es:
“Facilitar su adaptación cuando los requisitos inevitablemente cambien.”
Lecturas relacionadas
- Cambio en la arquitectura backend: de API fijas a sistemas de agentes AI — Aprenda cómo cambia el diseño backend cuando los agentes AI reemplazan las rutas API fijas, con ejemplos de código reales, casos de uso y consideraciones prácticas a tener en cuenta.