Inicio / Artículos / Estructuración de servicios en Node.js con módulos orientados a dominio y capas limpias

Estructuración de servicios en Node.js con módulos orientados a dominio y capas limpias

Aprenda cómo organizar una base de código Node.js en componentes basados en dominios, aplicar una arquitectura estricta de 3 capas y exponer utilidades compartidas a través de APIs públicas claras.

1300 palabras

Cuando se inicia un nuevo servicio de Node.js, el impulso crece rápidamente. Frameworks como Express, Fastify y NestJS permiten tener un endpoint REST o GraphQL en funcionamiento en cuestión de minutos. Pero a medida que los equipos crecen y la lógica empresarial se multiplica, suele surgir un problema conocido en estos conjuntos de código: se convierten en un desorden enredado y difícil de seguir, a menudo apodado “backend de espaguetis”.

Este artículo presenta un análisis en cuatro partes sobre prácticas de nivel empresarial para Node.js, centrado en establecer una base arquitectónica capaz de soportar el crecimiento del equipo. Verá cómo organizar el código según los dominios empresariales, mantener una separación clara entre las capas y crear módulos compartidos que no se conviertan en una carga.

1. Estructura por componentes empresariales (organización basada en dominios)

Un error recurrente en los proyectos de Node.js es organizar la base de código exclusivamente por función técnica en el nivel superior de la carpeta src.

El patrón antiétnico: estratificación técnica en la raíz

❌ AVOID: Layered by technical type
src/
├── controllers/
│   ├── userController.js
│   ├── orderController.js
│   └── paymentController.js
├── models/
│   ├── userModel.js
│   ├── orderModel.js
│   └── paymentModel.js
├── services/
│   ├── userService.js
│   ├── orderService.js
│   └── paymentService.js
└── routes/
    ├── userRoutes.js
    ├── orderRoutes.js
    └── paymentRoutes.js

Por qué la estratificación técnica falla a gran escala

  1. Baja localidad: Al desarrollar una sola funcionalidad —digamos, un flujo de trabajo de “reembolso de pedido”— se ve obligado a pasar entre cuatro o más carpetas no relacionadas.
  2. Límites poco claros: Dado que todo lo técnico está junto, los ingenieros terminan accediendo directamente a servicios y modelos distintos, lo que genera dependencias circulares y acoplamiento estricto.
  3. Carga cognitiva elevada: Una vez que se superan los 50 modelos, un directorio monolítico controllers/ o models/ se vuelve realmente difícil de navegar.

La solución: componentes de dominio modulares

Un enfoque mejor es organizar el sistema en torno a las capacidades empresariales, lo que el Diseño Impulsado por Dominios denomina Contextos Delimitados. Cada componente se convierte en un dominio autosuficiente, agrupando sus propios controladores, servicios, repositorios y modelos de dominio.

✅ PREFER: Component-based architecture
src/
├── components/
│   ├── users/
│   │   ├── users.controller.js
│   │   ├── users.service.js
│   │   ├── users.repository.js
│   │   └── users.routes.js
│   ├── orders/
│   │   ├── orders.controller.js
│   │   ├── orders.service.js
│   │   ├── orders.repository.js
│   │   └── orders.routes.js
│   └── payments/
│       ├── payments.controller.js
│       ├── payments.service.js
│       └── payments.repository.js
└── shared/
    ├── logger/
    └── database/

Ventajas:

  • Autosuficiencia: Todo lo relacionado con orders permanece dentro de una sola carpeta.
  • Preparación para microservicios: Si el componente payments se vuelve demasiado grande o complejo, es mucho más fácil extraerlo y convertirlo en un microservicio independiente, ya que sus dependencias están aisladas del resto de la aplicación.

2. Aplicar una arquitectura estricta de 3 capas dentro de los componentes

Dentro de cada componente empresarial, se debe mantener una separación clara entre tres capas:

┌─────────────────────────────────────────────────────────┐
│ 1. Entry Point / Transport Layer                        │
│    (Controllers, Event Subscribers, Route Handlers)    │
└───────────────────────────┬─────────────────────────────┘
                            │ Calls with plain DTOs
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 2. Domain / Business Layer                              │
│    (Services, Business Logic, Validation Rules)         │
└───────────────────────────┬─────────────────────────────┘
                            │ Calls repository methods
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 3. Data Access Layer                                    │
│    (Repositories, ORMs, Database Queries)              │
└─────────────────────────────────────────────────────────┘

La regla de oro: mantener los objetos web fuera de la lógica del dominio

Los servicios empresariales y los repositorios nunca deben recibir objetos específicos del framework: ni req ni res de Express, ni instancias de solicitud de Fastify.

La forma incorrecta: filtración de objetos de transporte HTTP

// orders.service.js
async function createOrderService(req, res) {
  // BAD: Service knows about HTTP headers, status codes, and req.body
  const userId = req.headers['x-user-id'];
  const orderData = req.body;

  if (!orderData.items || orderData.items.length === 0) {
    return res.status(400).json({ error: "Cart cannot be empty" });
  }

  const newOrder = await db.orders.insert({ userId, ...orderData });
  return res.status(201).json(newOrder);
}

Por qué esto falla:

  • createOrderService se vuelve inutilizable fuera de un contexto HTTP; no se puede llamar desde un script CLI, un consumidor de Kafka o RabbitMQ, ni desde una tarea cron, ya que ninguno de ellos proporciona req ni res.
  • Las pruebas ahora necesitan simular objetos de solicitud y respuesta HTTP en lugar de pasar valores normales de JavaScript.

La forma correcta: capa de servicios desacoplada

// orders.controller.js (Transport Layer)
const orderService = require('./orders.service');

async function handleCreateOrder(req, res, next) {
  try {
    // 1. Extract values from web context
    const userId = req.headers['x-user-id'];
    const { items, shippingAddress } = req.body;

    // 2. Call domain service with pure primitives/DTOs
    const order = await orderService.createOrder({
      userId,
      items,
      shippingAddress
    });

    // 3. Format HTTP response
    return res.status(201).json({ status: 'success', data: order });
  } catch (err) {
    next(err); // Defer error handling to central middleware
  }
}

// orders.service.js (Domain Layer)
const orderRepo = require('./orders.repository');

async function createOrder({ userId, items, shippingAddress }) {
  // 1. Pure business logic validation
  if (!items || items.length === 0) {
    throw new ValidationError('Order must contain at least one item.');
  }

  // 2. Business calculation
  const totalAmount = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  // 3. Persist via repository
  const createdOrder = await orderRepo.saveOrder({
    userId,
    items,
    shippingAddress,
    totalAmount,
    status: 'PENDING'
  });

  return createdOrder;
}

3. Modularizar las utilidades y aplicar APIs públicas limpias

A medida que una aplicación crece, surgen necesidades comunes como el registro de eventos, la autenticación, los auxiliares para conexiones a bases de datos y clientes para APIs externas, las cuales requieren atención por parte de muchos componentes diferentes. En lugar de permitir que cada consumidor acceda directamente a rutas de archivos internas complejas, se deben empaquetar estas utilidades compartidas como módulos internos con un punto de entrada público claramente definido.

El peligro de las importaciones profundas

// ❌ BAD: Tightly coupled to internal folder structures
const { formatLog } = require("../../shared/logger/utils/formatters/textFormatter.js");

Si el equipo encargado del registrador de eventos decide posteriormente reorganizar la estructura de sus carpetas internas, todos los consumidores que importaban desde esa ruta profunda dejarán de funcionar de inmediato.

Solución A: Exportación a través de index.js (CommonJS)

Se debe crear un único archivo de entrada que vuelva a exportar solo las partes destinadas al uso externo, ocultando todo lo demás.

// shared/logger/index.js
const { logger } = require('./loggerCore');
const { auditLog } = require('./auditLogger');

// Expose ONLY public functions
module.exports = {
  logger,
  auditLog
};

Los consumidores podrán entonces importar a través de esa interfaz sencilla en lugar de acceder a las partes internas:

// ✅ GOOD: Clean import via public interface
const { logger } = require('../../shared/logger');

Solución B: Exportación de paquetes con ESM (Node.js Workspaces / ESM moderno)

Si está trabajando con código o paquetes ESM modernos de Node.js dentro de un monorepo, utilice el campo exports en package.json para restringir explícitamente qué archivos pueden ser importados desde fuera del paquete.

// shared/logger/package.json
{
  "name": "@my-app/logger",
  "version": "1.0.0",
  "main": "./src/index.js",
  "exports": {
    ".": "./src/index.js"
  }
}

Con esta configuración en vigor, cualquier intento de importar una ruta privada como @my-app/logger/src/internal/formatter.js fallará con un error en tiempo de ejecución, lo que proporciona a su equipo un límite claro que evita el acoplamiento accidental con los detalles de implementación.

Lista de verificación de arquitectura para la Parte 1

Antes de pasar a la gestión de errores y los flujos de trabajo asíncronos, utilice esta lista de verificación para confirmar que su código sigue los principios mencionados anteriormente:

  • Organización del dominio: ¿Está el código estructurado por componentes funcionales (orders, users) en lugar de carpetas técnicas genéricas (controllers, models)?
  • Servicios puramente comerciales: ¿Las funciones de servicio están exentas de argumentos relacionados con la capa de transporte como req, res o next?
  • Interfases explícitas: ¿Los módulos compartidos presentan una interfaz pública deliberadamente restringida, ya sea a través de un archivo index.js o de las exportaciones de package.json?
  • Flujo de datos correcto: ¿Los datos se mueven estrictamente hacia abajo, desde el controlador hasta el servicio y luego al repositorio, sin que las capas inferiores intenten importar desde las superiores?

Lecturas relacionadas

  • Por qué decodificar los bloques del buffer como texto daña las subidas de archivos — Explica cómo tratar los datos binarios del buffer como texto UTF-8 corrompe silenciosamente los archivos subidos y muestra el manejo correcto a nivel de bytes para evitarlo.