Inicio / Artículos

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.

Editor: REACTAPP.TOP10 min de lectura2199 palabras

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
  • Notificaciones
  • Procesamiento de tareas en segundo plano
  • Caché basado en Redis
  • 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:

    1. Analizar la solicitud recibida
    2. Validar los datos de entrada
    3. Verificar la autenticación
    4. Ejecutar la lógica de negocio
  • Hablar con la base de datos
  • Dar forma a la respuesta
  • Identificar y manejar errores
  • 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
  • Cómo se hashean las contraseñas
  • Si es necesario enviar un correo de confirmación
  • Si Redis interviene en algún momento
  • 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
  • Valores permitidos
  • 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

  • Cómo process.nextTick() priva en silencio al bucle de eventos de Node.js — Explica por qué las llamadas recursivas a process.nextTick() bloquean completamente la fase de sondeo de libuv y cómo solucionar el problema de privación del bucle de eventos utilizando setImmediate().
  • Pruebas en Node.js para empresas: nomenclatura AAA, cobertura y fábricas de pruebas — Aprenda cómo estructurar las pruebas en Node.js con el patrón AAA, equilibrar la cobertura de unidades e integración, verificar cinco resultados clave del dominio y aislar pruebas mediante fábricas.
  • Comprendiendo las claves de idempotencia en los endpoints POST de Node.js — Explica por qué las solicitudes POST fallan de manera impredecible al intentarse nuevamente y cómo las claves de idempotencia generadas por el cliente permiten a las APIs de Node.js manejar de forma segura las solicitudes duplicadas.
  • Estructurando servicios de Node.js con módulos gestionados por el dominio y capas limpias — Aprenda cómo organizar una base de código de Node.js en componentes basados en el dominio, aplicar una arquitectura estricta de 3 capas y exponer utilidades compartidas a través de APIs públicas limpias.