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.
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
- 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.
- 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.
- Carga cognitiva elevada: Una vez que se superan los 50 modelos, un directorio monolítico
controllers/omodels/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
orderspermanece dentro de una sola carpeta. - Preparación para microservicios: Si el componente
paymentsse 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:
createOrderServicese 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 proporcionareqnires.- 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,resonext? - Interfases explícitas: ¿Los módulos compartidos presentan una interfaz pública deliberadamente restringida, ya sea a través de un archivo
index.jso de las exportaciones depackage.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
- 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 enredada, los errores inconsistentes y las dificultades de escalado.
- Seis reglas de DDD para estructurar dominios en aplicaciones NestJS — Conozca seis reglas prácticas de diseño orientado a dominios para organizar módulos, entidades y eventos en NestJS, de modo que las funcionalidades permanezcan aisladas y fáciles de mantener.