Una arquitectura a prueba de balas para proyectos Node.js 🛡️

Santiago Quinteros - CEO & CTO - Software on the road
By:
Santiago Quinteros

Actualización 21/04/2019: Repositorio de ejemplos de aplicación

Introducción

Express.js es un gran marco de trabajo para hacer REST APIs en node.js sin embargo no te da ninguna pista de cómo organizar tu proyecto node.js.

Aunque pueda parecer una tontería, este es un verdadero problema.

La correcta organización de la estructura de tu proyecto node.js evitará la duplicación de código, mejorará la estabilidad y, potencialmente, te ayudará a escalar tus servicios si se hace correctamente.

Este post es una investigación extensa, de mis años de experiencia tratando con un proyecto node.js mal estructurado, malos patrones, e incontables horas de refactorización de código y de mover cosas de un lugar a otro.

Si necesitas ayuda para alinear la arquitectura de tu proyecto node.js, envíame un email a sam@softwareontheroad.com

Tabla de contenido

La estructura de la carpeta 🏢

Aquí está la estructura del proyecto node.js del que estoy hablando.

Utilizo esto en cada servicio REST API de node.js que construyo, veamos en detalle lo que hace cada componente.

src
│   app.js          # App entry point
└───api             # Express route controllers for all the endpoints of the app
└───config          # Environment variables and configuration related stuff
└───jobs            # Jobs definitions for agenda.js
└───loaders         # Split the startup process into modules
└───models          # Database models
└───services        # All the business logic is here
└───subscribers     # Event handlers for async task
└───types           # Type declaration files (d.ts) for Typescript

Es más que una forma de pedir archivos javascript...

Arquitectura de 3 capas 🥪

La idea es utilizar el **principio de separación de conceptos** para alejar la lógica empresarial de las Rutas API de nodo.js.

Patrón de 3 capas

Porque algún día, querrás usar tu lógica de negocios en una herramienta CLI, o sin ir muy lejos, en una tarea recurrente.

Y hacer una llamada API desde el servidor node.js a sí mismo no es una buena idea...

Patrón de 3 capas para node.js REST API

☠️ No pongas tu lógica de negocios dentro de los controladores!!! ☠️

Puedes estar tentado a utilizar los controladores express.js para almacenar la lógica de negocios de tu aplicación, pero esto se convierte rápidamente en código de espagueti, tan pronto como necesite escribir pruebas de unidad, terminará tratando con practicas complejas para los objetos req o res express.js.

Es complicado distinguir cuándo debe enviarse una respuesta y cuándo continuar el procesamiento en "segundo plano", digamos después de que la respuesta se envía al cliente.

He aquí un ejemplo de lo que no se debe hacer.

route.post('/', async (req, res, next) => {

  // Esto debería ser un middleware o debería ser manejado por una biblioteca como Joi.
  const userDTO = req.body;
  const isUserValid = validators.user(userDTO)
  if(!isUserValid) {
    return res.status(400).end();
  }

  // Hay mucha lógica de negocios aquí...
  const userRecord = await UserModel.create(userDTO);
  delete userRecord.password;
  delete userRecord.salt;
  const companyRecord = await CompanyModel.create(userRecord);
  const companyDashboard = await CompanyDashboard.create(userRecord, companyRecord);

  ...En fin...


  // Y aquí está la "optimización" que lo estropea todo.
  // La respuesta se envía al cliente...
  res.json({ user: userRecord, company: companyRecord });

  // Pero la ejecución del código continúa. :(
  const salaryRecord = await SalaryModel.create(userRecord, companyRecord);
  eventTracker.track('user_signup',userRecord,companyRecord,salaryRecord);
  intercom.createUser(userRecord);
  gaAnalytics.event('user_signup',userRecord);
  await EmailService.startSignupSequence(userRecord)
});

Use una capa de servicio para su lógica de negocios 💼

Esta capa es donde debería vivir su lógica de negocios.

Es sólo una colección de clases con propósitos claros, siguiendo los principios SOLID aplicados a node.js.

En esta capa no debería existir ninguna forma de 'consulta SQL', usa la capa de acceso a datos para eso._

  • Aleja tu código del enrutador express.js

  • No pases el objeto req o res a la capa de servicio

  • No devuelvas nada relacionado con la capa de transporte HTTP como códigos de estado o encabezados de la capa de servicio.

Ejemplo

route.post('/', 
  validators.userSignup, // este middleware se encarga de la validación
  async (req, res, next) => {
    // La responsabilidad real de la capa de ruta.
    const userDTO = req.body;

    // Llama a la capa de servicio.
    // Abstracción sobre cómo acceder a la capa de datos y la lógica de negocio.
    const { user, company } = await UserService.Signup(userDTO);

    // Devolver una respuesta al cliente.
    return res.json({ user, company });
  });

Así es como su servicio trabajará detrás de escena.

import UserModel from '../models/user';
import CompanyModel from '../models/company';

export default class UserService() {

  async Signup(user) {
    const userRecord = await UserModel.create(user);
    const companyRecord = await CompanyModel.create(userRecord); // necesita que userRecord tenga el ID de la base de datos 
    const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // depende del usuario y de la empresa que se cree
    
    ...En fin
    
    await EmailService.startSignupSequence(userRecord)

    ...Haz más cosas

    return { user: userRecord, company: companyRecord };
  }
}

Visite el repositorio de ejemplos

Usa también una capa de Pub/Sub 🎙️

El patrón de pub/sub va más allá de la clásica arquitectura de 3 capas propuesta aquí, pero es extremadamente útil.

El simple API endpoint de node.js que crea un usuario en este momento, puede querer llamar a servicios de terceros, tal vez a un servicio de análitico, o tal vez iniciar una secuencia de correo electrónico.

Más pronto que tarde, esa simple operación de "crear" hará varias cosas, y terminará con 1000 líneas de código, todo en una sola función.

Eso viola el principio de responsabilidad única.

Por lo tanto, es mejor separar las responsabilidades desde el principio, para que tu código se mantenga.

import UserModel from '../models/user';
import CompanyModel from '../models/company';
import SalaryModel from '../models/salary';

export default class UserService() {

  async Signup(user) {
    const userRecord = await UserModel.create(user);
    const companyRecord = await CompanyModel.create(user);
    const salaryRecord = await SalaryModel.create(user, salary);

    eventTracker.track(
      'user_signup',
      userRecord,
      companyRecord,
      salaryRecord
    );

    intercom.createUser(
      userRecord
    );

    gaAnalytics.event(
      'user_signup',
      userRecord
    );
    
    await EmailService.startSignupSequence(userRecord)

    ...más cosas

    return { user: userRecord, company: companyRecord };
  }

}

Una llamada imperativa a un servicio dependiente no es la mejor manera de hacerlo.

Un mejor enfoque es emitiendo un evento, es decir, "un usuario se registró con este correo electrónico".

Y ya está, ahora es responsabilidad de los listeners hacer su trabajo.

import UserModel from '../models/user';
import CompanyModel from '../models/company';
import SalaryModel from '../models/salary';

export default class UserService() {

  async Signup(user) {
    const userRecord = await this.userModel.create(user);
    const companyRecord = await this.companyModel.create(user);
    this.eventEmitter.emit('user_signup', { user: userRecord, company: companyRecord })
    return userRecord
  }

}

Ahora puedes dividir los eventos handlers/listeners en múltiples archivos.

eventEmitter.on('user_signup', ({ user, company }) => {

  eventTracker.track(
    'user_signup',
    user,
    company,
  );

  intercom.createUser(
    user
  );

  gaAnalytics.event(
    'user_signup',
    user
  );
})
eventEmitter.on('user_signup', async ({ user, company }) => {
  const salaryRecord = await SalaryModel.create(user, company);
})
eventEmitter.on('user_signup', async ({ user, company }) => {
  await EmailService.startSignupSequence(user)
})

Puedes envolver las declaraciones await en un bloque de try-catch o puedes dejar que falle y manejar la 'unhandledPromise'process.on('unhandledRejection',cb)

Inyección de dependencia 💉

La D.I. o inversión de control (IoC) es un patrón común que ayudará a la organización de su código, al "inyectar" o pasar a través del constructor las dependencias de su clase o función.

De este modo, obtendrás la flexibilidad necesaria para inyectar una dependencia compatible cuando, por ejemplo, escribas las pruebas de unidad para el servicio, o cuando el servicio se utilice en otro contexto.

Código sin D.I

import UserModel from '../models/user';
import CompanyModel from '../models/company';
import SalaryModel from '../models/salary';  
class UserService {
  constructor(){}
  Sigup(){
    // Llamando al UserModel, CompanyModel, etc
    ...
  }
}

Código con inyección de dependencia manual

export default class UserService {
  constructor(userModel, companyModel, salaryModel){
    this.userModel = userModel;
    this.companyModel = companyModel;
    this.salaryModel = salaryModel;
  }
  getMyUser(userId){
    // modelos disponibles a través de "esto"
    const user = this.userModel.findById(userId);
    return user;
  }
}

Ahora puedes inyectar dependencias personalizadas.

import UserService from '../services/user';
import UserModel from '../models/user';
import CompanyModel from '../models/company';
const salaryModelMock = {
  calculateNetSalary(){
    return 42;
  }
}
const userServiceInstance = new UserService(userModel, companyModel, salaryModelMock);
const user = await userServiceInstance.getMyUser('12346');

La cantidad de dependencias que puede tener un servicio es infinita, y refactorizar cada instanciamiento del mismo cuando se agrega uno nuevo es una tarea aburrida y propensa a errores.

Es por eso que se crearon frameworks de inyección de dependencia.

La idea es que declaras tus dependencias en la clase, y cuando necesites una instancia de esa clase, sólo tienes que llamar al "Localizador de Servicio".

Veamos un ejemplo usando typedi una biblioteca npm que lleva el D.I. a node.js

Puede leer más sobre cómo usar el typedi en la documentación oficial

Ejemplo de typescripts WARNING

import { Service } from 'typedi';
@Service()
export default class UserService {
  constructor(
    private userModel,
    private companyModel, 
    private salaryModel
  ){}

  getMyUser(userId){
    const user = this.userModel.findById(userId);
    return user;
  }
}

services/user.ts

Ahora typedi se encargará de resolver cualquier dependencia que el UserService requiera.

import { Container } from 'typedi';
import UserService from '../services/user';
const userServiceInstance = Container.get(UserService);
const user = await userServiceInstance.getMyUser('12346');

Abusar de las llamadas del localizador de servicios es un anti-patrón

Usando la Inyección de Dependencia con Express.js en Node.js

Usar D.I. en express.js es la última pieza del rompecabezas para la arquitectura de este proyecto node.js.

Routing layer

route.post('/', 
  async (req, res, next) => {
    const userDTO = req.body;

    const userServiceInstance = Container.get(UserService) // Localizador de servicio

    const { user, company } = userServiceInstance.Signup(userDTO);

    return res.json({ user, company });
  });

Impresionante, el proyecto se ve muy bien! Está tan organizado que me hace querer estar codeando algo ahora mismo. Visit the example repository

Un ejemplo de prueba de unidad 🕵🏻

Al usar la inyección de dependencia y estos patrones de organización, la prueba de unidad se vuelve realmente simple.

No tienes que hacer una simulación de los objetos req/res o requerir(...) llamadas.

Ejemplo: Prueba unitaria para el método de registro de usuarios test/unit/services/user.js

import UserService from '../../../src/services/user';

describe('User service unit tests', () => {
  describe('Signup', () => {
    test('Should create user record and emit user_signup event', async () => {
      const eventEmitterService = {
        emit: jest.fn(),
      };

      const userModel = {
        create: (user) => {
          return {
            ...user,
            _id: 'mock-user-id'
          }
        },
      };

      const companyModel = {
        create: (user) => {
          return {
            owner: user._id,
            companyTaxId: '12345',
          }
        },
      };

      const userInput= {
        fullname: 'User Unit Test',
        email: 'test@example.com',
      };

      const userService = new UserService(userModel, companyModel, eventEmitterService);
      const userRecord = await userService.SignUp(teamId.toHexString(), userInput);

      expect(userRecord).toBeDefined();
      expect(userRecord._id).toBeDefined();
      expect(eventEmitterService.emit).toBeCalled();
    });
  })
})

Trabajos en cola y tarea recurrente ⚡

Así que, ahora que la lógica de negocios está encapsulada en la capa de servicio, es más fácil de usar desde una cola de trabajo.

Nunca debes confiar en setTimeout de node.js u otra forma primitiva de retrasar la ejecución del código, sino en un framework que persiga tus trabajos, y la ejecución de los mismos, en una base de datos.

De esta manera tendrás el control sobre los trabajos fallidos, y la retroalimentación de aquellos que tienen éxito. Ya escribí sobre las buenas prácticas para esto, así que, Revisa mi guía sobre el uso de agenda.js el mejor administrador de tareas para node.js.

Configuraciones y secretos 🤫

Siguiendo los conceptos batte-tested de La aplicación de doce factores para el node.js el mejor enfoque para almacenar claves API y conexiones de cadenas de bases de datos, es usando dotenv.

Ponga un archivo .env, que nunca debe ser commiteado (pero tiene que existir con los valores por defecto en su repositorio) entonces, el paquete npm dotenv carga el archivo .env e inserta los vars en el objeto process.env de node.js.

Eso podría ser suficiente pero, me gusta añadir un paso más. Tengo un archivo config/index.ts donde el paquete npm dotenv carga el archivo .env y luego uso un objeto para almacenar las variables, así tenemos una estructura y código con autocompletado.

config/index.js

const dotenv = require('dotenv');
// config() leerá su archivo .env, analizará el contenido, lo asignará a process.env.
dotenv.config();

export default {
  port: process.env.PORT,
  databaseURL: process.env.DATABASE_URI,
  paypal: {
    publicKey: process.env.PAYPAL_PUBLIC_KEY,
    secretKey: process.env.PAYPAL_SECRET_KEY,
  },
  paypal: {
    publicKey: process.env.PAYPAL_PUBLIC_KEY,
    secretKey: process.env.PAYPAL_SECRET_KEY,
  },
  mailchimp: {
    apiKey: process.env.MAILCHIMP_API_KEY,
    sender: process.env.MAILCHIMP_SENDER,
  }
}

De esta manera evitas inundar tu código con instrucciones "process.env.MY_RANDOM_VAR", y al tener el autocompletado no tienes que saber cómo nombrar el env var.

Visite el repositorio de ejemplo

Cargadores 🏗️

Tomé este patrón de W3Tech microframework pero sin depender de su paquete.

La idea es que dividas el proceso de inicio de tu servicio node.js en módulos testeables.

Veamos una inicialización clásica de la aplicación express.js

const mongoose = require('mongoose');
const express = require('express');
const bodyParser = require('body-parser');
const session = require('express-session');
const cors = require('cors');
const errorhandler = require('errorhandler');
const app = express();

app.get('/status', (req, res) => { res.status(200).end(); });
app.head('/status', (req, res) => { res.status(200).end(); });
app.use(cors());
app.use(require('morgan')('dev'));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json(setupForStripeWebhooks));
app.use(require('method-override')());
app.use(express.static(__dirname + '/public'));
app.use(session({ secret: process.env.SECRET, cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });

require('./config/passport');
require('./models/user');
require('./models/company');
app.use(require('./routes'));
app.use((req, res, next) => {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});
app.use((err, req, res) => {
  res.status(err.status || 500);
  res.json({'errors': {
    message: err.message,
    error: {}
  }});
});


... más cosas 

... quizas iniciar Redis

...quizas agregar más middlewares

async function startServer() {    
  app.listen(process.env.PORT, err => {
    if (err) {
      console.log(err);
      return;
    }
    console.log(`Your server is ready !`);
  });
}

// Ejecuta la función async para iniciar nuestro servidor
startServer();

Como ves, esta parte de tu aplicación puede ser un verdadero desastre.

Aquí hay una forma efectiva de tratar con él.

const loaders = require('./loaders');
const express = require('express');

async function startServer() {

  const app = express();

  await loaders.init({ expressApp: app });

  app.listen(process.env.PORT, err => {
    if (err) {
      console.log(err);
      return;
    }
    console.log(`Your server is ready !`);
  });
}

startServer();

Ahora los cargadores son sólo pequeños archivos con un propósito conciso loaders/index.js

import expressLoader from './express';
import mongooseLoader from './mongoose';

export default async ({ expressApp }) => {
  const mongoConnection = await mongooseLoader();
  console.log('MongoDB Initialized');
  await expressLoader({ app: expressApp });
  console.log('Express Initialized');

  // ... más cargadores pueden estar aquí

  // ... Iniciar agenda
  // ... o Redis, o lo que quieras
}

El cargador exprés

loaders/express.js


import * as express from 'express';
import * as bodyParser from 'body-parser';
import * as cors from 'cors';

export default async ({ app }: { app: express.Application }) => {

  app.get('/status', (req, res) => { res.status(200).end(); });
  app.head('/status', (req, res) => { res.status(200).end(); });
  app.enable('trust proxy');

  app.use(cors());
  app.use(require('morgan')('dev'));
  app.use(bodyParser.urlencoded({ extended: false }));

  // ...Más middlewares

  // Devuelve la aplicación express
  return app;
})

El cargador de mongo

loaders/mongoose.js

import * as mongoose from 'mongoose'
export default async (): Promise<any> => {
  const connection = await mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });
  return connection.connection.db;
}

Conclusión

Nos sumergimos profundamente en un proyecto esctructurado en node.js probado en producción, aquí hay algunos consejos resumidos:

  • Usar una arquitectura de 3 capas.

  • No pongas tu lógica de negocios en los controladores express.js.

  • Use el patrón PubSub y emita eventos para las tareas que corren por detrás.

  • Tenga una inyección de dependencia para su tranquilidad.

  • Nunca filtre sus contraseñas, secretos y claves de la API, utilice un administrador de configuración.

  • Divide las configuraciones de tu servidor node.js en pequeños módulos que pueden ser cargados independientemente.

Vea el repositorio de ejemplo aquí

Get the latest articles in your inbox.

Join the other 2000+ savvy node.js developers who get article updates. You will receive only high-quality articles about Node.js, Cloud Computing and Javascript front-end frameworks.


santypk4

CEO at Softwareontheroad.com