6 minutos de leitura

Arquitetura de projeto node.js à prova de balas é um artigo feito a partir de uma tradução do material que foi escrito por Sam Quinn e está disponível na página do softwareontheroad.com no seguinte link.

Introdução

O Express.js é um ótimo framework para criar APIs REST com node.js, mas não nos dá nenhuma pista sobre como organizar nossa Arquitetura de projeto.

Embora possa parecer simples, este é um problema real.

Organizar a estrutura de um projeto em node.js irá evitar a duplicação de código, irá melhorar a estabilidade e, potencialmente, ajudará você a dimensionar seus serviços se for feito da forma certa.

LEIA TAMBÉM: Impacto da IA: como os desenvolvedores estão utilizando e enxergando essa tecnologia?

Este artigo é uma extensa pesquisa dos meus anos de experiência lidando com projetos node.js mal estruturados, padrões ruins e incontáveis horas reescrevendo códigos e ajustando coisas.

A Estrutura de Pastas 🏢

Aqui está a arquitetura do projeto node.js da qual estou falando.

Eu uso isso em todos os serviços de node.js REST API que crio, vamos ver em detalhes o que cada componente faz.
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

É mais do que apenas uma maneira de ordenar arquivos javascript…

Arquitetura de 3 camadas 🥪

A ideia é usar o princípio da separação de interesses para afastar a lógica de negócios das rotas da API.

Node.js Arquitetura de 3 camadas

Porque algum dia, você vai querer usar sua lógica de negócios em uma ferramenta CLI, ou até mesmo algo mais próximo, como uma tarefa recorrente.

E fazer uma conexão da API do servidor node.js para ela mesmo, não é uma boa ideia…

☠️ Não coloque sua lógica de negócios dentro dos controllers!!☠️

Você pode ficar tentado usar os controllers para armazenar a lógica de negócios do seu aplicativo, mas isso rapidamente se torna um código sem estrutura e difícil de compreender, no momento em que precisar fazer testes de unidade, você acabará lidando com simulações complexas para objetos express.js como req ou res.

É complicado distinguir quando uma resposta deve ser enviada e quando continuar o processamento em ‘background’, digamos depois que a resposta é enviada ao cliente.

Aqui está um exemplo do que não fazer.

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


// This should be a middleware or should be handled by a library like Joi.
const userDTO = req.body;
const isUserValid = validators.user(userDTO)
if(!isUserValid) {
return res.status(400).end();

}


// Lot of business logic here...
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);


...whatever...


// And here is the 'optimization' that mess up everything.
// The response is sent to client...
res.json({ user: userRecord, company: companyRecord });

// But code execution continues :(
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 uma camada de serviço para sua lógica de negócios 💼

Essa camada é onde sua lógica de negócios deve residir.

É apenas uma coleção de classes com propósitos claros, seguindo os princípios do SOLID aplicados ao node.js.

Nesta camada não deve existir nenhuma forma de ‘SQL Querry’, use a camada de acesso a dados para isso.

  • Mova seu código para longe do express.js router
  • Não passe o objeto req ou res para a camada de serviço
  • Não retorne nada relacionado à camada de transporte HTTP, como um status code ou headers da camada de serviço.

Exemplo:

route.post('/',
validators.userSignup, // this middleware take care of validation
async (req, res, next) => {
// The actual responsability of the route layer.
const userDTO = req.body;

// Call to service layer.
// Abstraction on how to access the data layer and the business logic.
const { user, company } = await UserService.Signup(userDTO);

// Return a response to client.
return res.json({ user, company });
});

Aqui está como seu serviço funcionará por de trás dos panos.

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); // needs userRecord to have the database id
const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // depends on user and company to be created

...whatever

await EmailService.startSignupSequence(userRecord)

...do more stuff

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

Visit the example repository

Use uma camada Pub/Sub também 🎙️

O padrão pub/sub vai além da arquitetura clássica de 3 camadas proposta aqui, e é extremamente útil.

O endpoint simples da API node.js, que cria um usuário instantaneamente, pode querer chamar serviços de terceiros, talvez para um serviço de análise ou então iniciar uma sequência de e-mail.

Mais cedo ou mais tarde, essa simples operação de “criar” fará várias coisas e você terminará com 1.000 linhas de código, tudo em uma única função.

Isso viola o princípio da responsabilidade única.

Portanto, é melhor separar as responsabilidades desde o início, para que seu código permaneça sustentável.

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
}

}

Uma chamada obrigatória para um serviço dependente não é a melhor maneira de fazer isso.

Uma abordagem melhor é emitir um evento, ou seja, ‘um usuário se inscreveu com este e-mail’.

E pronto, agora é responsabilidade dos listeners fazerem seu trabalho.

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
}

}

Agora você pode dividir os eventos de handlers/listeners em vários arquivos.

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)
})

Você pode agrupar as instruções await em um bloco try-catch ou [você pode simplesmente deixá-lo falhar e lidar com o ‘unhandledPromise’ process.on(‘unhandledRejection’,cb)

Injeção de Dependência 💉

Injeção de Dependência (ID) ou inversão de controle (IoC) é um padrão comum que ajudará na organização do seu código, “injetando” ou passando pelo construtor as dependências de sua classe ou função.

Ao fazer isso, você terá a flexibilidade de injetar uma ‘dependência compatível’ quando, por exemplo, escrever os testes de unidade para o serviço ou quando o serviço for usado em outro contexto.

Código sem ID

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

Código com injeção manual de dependência

export default class UserService {
constructor(userModel, companyModel, salaryModel){
this.userModel = userModel;
this.companyModel = companyModel;
this.salaryModel = salaryModel;
}
getMyUser(userId){
// models available throug 'this'
const user = this.userModel.findById(userId);
return user;
}
}

Agora você pode injetar dependências 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');

A quantidade de dependências que um serviço pode ter é infinita, e refatorar cada instanciação dele quando você adiciona um novo é uma tarefa chata e que abre espaços para erros.

É por isso que os frameworks de injeção de dependência foram criados.

A ideia é você declarar suas dependências na classe, e quando precisar de uma instância dessa classe, basta chamar o ‘Service Locator’.

Vamos ver um exemplo usando typedi uma biblioteca npm que traz ID para node.js

Você pode ler mais sobre como usar o typedi na documentação oficial

AVISO exemplo de typescript

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

Agora, o typedi irá resolver qualquer dependência exigida pelo UserService.

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

Usando injeção de dependência com Express.js em Node.js

Usando Injeção de Dependencias. em express.js é a peça final do quebra-cabeça para esta arquitetura de projeto node.js.

Camada de roteamento

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

const userServiceInstance = Container.get(UserService) // Service locator

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

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

Incrível, o projeto está ficando ótimo! É tão organizado que me faz querer codar agora mesmo.

Um exemplo de teste unitário 🕵🏻

Ao usar a injeção de dependência e esses padrões de organização, o teste unitário se torna realmente simples.

Você não precisa simular objetos req/res ou require(…) calls.

tests/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();
});
})
})

Cron Jobs and recurring task

Com isso, agora que a lógica de negócios está encapsulada na camada de serviço, é mais fácil usá-la a partir de um Cron Job.

Você nunca deve confiar em node.js setTimeout ou outra forma antiga de atrasar a execução do código, mas em uma arquitetura de projeto que mantenha seus jobs e a execução deles em um banco de dados.

Dessa forma, você terá controle sobre os jobs que falharam e feedback daqueles que obtiveram sucesso. Eu já escrevi sobre boas práticas para isso, confira meu guia sobre como usar agenda.js, o melhor gerenciador de tarefas para node.js.

Configurações e segredos 🤫

Seguindo os conceitos amplamente testados do Twelve-Factor App para node.js, a melhor abordagem para armazenar chaves de API e conexões de string de banco de dados, é usar o dotenv.

Coloque um arquivo .env , que nunca deve ser commitado (mas tem que existir com valores default em seu repositório) então, o pacote npm dotenv carrega o arquivo .env e insere os vars no processo . env de node.js.

Isso pode ser suficiente, mas eu gosto de adicionar um passo extra. Ter um arquivo config/index.ts onde o pacote dotenv npm carrega o arquivo .env e então eu uso um objeto para armazenar as variáveis, assim temos uma estrutura de código que se autocomplementa.

config/index.js

const dotenv = require('dotenv');
// config() will read your .env file, parse the contents, assign it to 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,
}
}

Dessa forma, você evita floodar seu código com instruções process.env.MY_RANDOM_VAR, usando o preenchimento automático, você não precisa saber como nomear o env var.

Loaders🏗️

Peguei esse padrão de W3Tech microframework mas sem depender de seu package.

A ideia é que você divida o processo de inicialização do seu serviço node.js em módulos testáveis.

Vamos ver uma inicialização clássica do aplicativo 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: {}
}});
});

... more stuff

... maybe start up Redis

... maybe add more middlewares

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

// Run the async function to start our server
startServer();

Como você pode ver, esta parte do seu aplicativo pode ser uma verdadeira bagunça.

Essa é uma forma eficiente de resolve-la

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();

Agora os Loaders são apenas pequenos arquivos com um 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');

// ... more loaders can be here

// ... Initialize agenda
// ... or Redis, or whatever you want
}

O express loader

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 }));

// ...More middlewares

// Return the express app
return app;
})

O mongo Loader

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;
}

Conclusão

Nós nos aprofundamos em uma arquitetura de projeto node.js testada em produção, aqui estão algumas dicas resumidas:

  • Use uma arquitetura de 3 camadas.
  • Não coloque sua lógica de negócios nos controladores express.js.
  • Use o padrão PubSub e libera eventos para tarefas em background.
  • Tenha injeção de dependência para acalmar sua alma.
  • Nunca vaze suas senhas, segredos e chaves de API, use um gerenciador de configuração.
  • Divida as configurações do seu servidor node.js em pequenos módulos que podem ser carregados independentemente.
Você pode também gostar