Como organizar e estruturar projetos  com node.js?

Como organizar e estruturar projetos com node.js?

No node temos muita liberdade para construir nossa API REST da forma que desejarmos, quem está começando não sabe ao certo como organizar.

Talvez em projetos pequenos você não perceba os problemas que isso pode te causar, projetos maiores onde visa escalonar, provavelmente terá problemas com essa organização para manter o código com um alto acoplamento trazendo falta de reutilização de código, falta de estabilidade...

Por isso nesse artigo vamos falar sobre uma estrutura que irá ajudar a trazer uma melhor sustentabilidade e escalonabilidade para sua aplicação.

Estrutura de pastas 📚

src
│   app.js          # Classe app
│   server.js       # Server para iniciar o app
└───api             
  └───controllers   # Funções da controllers do express route
  └───models        # Modelos do banco de dados
  └───services      # Regras de negócio
  └───subscribers   # Eventos async 
  └───repositories* # Query builders 
└───config          # Configuração das variaveis de ambiente
└───jobs            # Tarefas de rotinas
└───loaders         # Modulos para utilizado no app
└───utils           # Trechos de código pequeno
└───helpers         # Trechos de arquitetura de código
└───routes          # Definição de rotas express
└───types           # Tipagem (d.ts) para Typescript

Lembrando que cada projeto tem suas peculiaridades, então basta você/equipe decidir o que é melhor para adicionar ou remover no seu projeto visando sempre o equilibrio entre agilidade e qualidade na entrega do produto.

… controllers e os fardos que elas carregam

A prática de atribuir bastantes regras de negócio nos controllers express.js é de fácil visualização, pois na cabeça de quem está começando tudo é uma coisa só.

E com isso uma controller que começou tendo 300 linhas de código com o passar do tempo ela pode ficar com mais de 1000 linhas. Pois você estará implementando querys builders, regras de negócios, chamada para serviços externos e muito mais…

Com isso para testar, reaproveitar código e dar manutenção que deveria ser um trabalho mais fácil acaba se tornando muito complicado.

async create('/client', async (req, res, next) => {
  const client = req.body;

  const isClientValid = validators.client(client);

  if(!isClientValid) {
    return res.status(400).json({ message: 'Client data is invalid' });
  }

  // Regras de negócios
  const clientRecord = await ClientModel.create(client);  
  delete clientRecord.password;  
  const partyRecord = await PartyModel.create(clientRecord);

  // Enviamos a resposta para o cliente logo como forma de otimizar o processo, isso é um erro
  res.json({ client: clientRecord, party: partyRecord });

  // Mesmo depois de enviar esse trecho do código continua rodando
  analytics.event('client_register', clientRecord);
  await EmailService.startSignupSequence(clientRecord)
});

Acima segue um exemplo ruim de uma controller onde atribuimos todas as responsabilidades em uma única camada, vamos adotar princípio da responsabilidade única do SOLID para fazer a separação.

“Uma classe deve ter um, e apenas um, motivo para ser modificada”

Ao separar as controllers das regras de negócio o que ganhamos?

Reutilização de código em outras classes, fazer testes únitarios e de integração ficará mais claro o que de fato você terá que testar em cada um, fácil manutenção já que iremos granularizar nossas responsabilidades…

Controllers

O controle deve se preocupar em aceitar a solicitação, repassar para o serviço de domínio correto processe a solicitação e entregue a resposta ao cliente.

route.post('/client', async (req, res, next) => {
  try {
    const response = await ClientService.create(req.body);
    return res.status(201).json(response);
  } catch (e) {
    return next(e);
  }
});

Services

Essa camada é um design pattern que ajuda a abstrair suas regras de negócio, deixando sua controller mais limpa e com a responsabilidade única.

Um outro ponto importante que a medida que cresce sua aplicação você tende a reutilizar os códigos já implementados nesta camada. Imagine que você tem três controllers que faz uso de um service e você precisa alterar alguma parte do código, obviamente você vai utilizar somente a função no service para alterar, entretanto se não tivessemos essa camada? Teriamos sair procurando no nosso projeto todos os lugares que faz o uso daquele trecho de código.

async create(client) { 
  validators.client(client);

  const clientRecord = await ClientModel.create(client);  
  delete clientRecord.password;  

  const partyRecord = await PartyService.create(clientRecord);

  return { client: clientRecord, party: partyRecord };  
}

Para fazer testes unitários será muito mais fácil, sem necessidade de fazer uma request a um endpoint.

Repositories*

Ter querys sql no código de uma service isso torna um código grande e ilegível, por isso atribuimos aos repositories o trabalho de ser uma camada de acesso e interação com as entidades do banco de dados.

Temos dois pontos que podemos utilizar para falar da utilização de um repository, *centralizar regras de recuperação e persistência de dados e **abstrair a utilização de ORMs possibilitando a troca por outros ORMs,* mas vamos falar a verdade é muito dificil de um projeto ficar trocando de ORM**.

Vale ressaltar que no mundo ideal levariamos a risca o S de SOLID e com isso cada classe teria sua responsabilidade única, mas sabemos que não temos tanto tempo para fazer isso no dia-a-dia por isso nem sempre vai ser necessário ter uma classe no repo, pois o metodo utilizado pode ser somente um findAll(). Isso vai da avaliação do programador para saber se é necessário a utilização dessa camada.

Subscribers (Pub/Sub)

Quando se tem uma aplicação onde ela utiliza serviços de terceiros e geralmente fazemos uso na camada de controller junto com a regras de negócio com o tempo a aplicação crescendo é muito provável que iremos acrescentar mais linhas de códigos de serviços externos.

Abordagem de utilizar um serviço de terceiro de forma imperativo não é a melhor opção para esse caso, por isso é bom trabalhar com eventos sendo emitidos para cada subscriber depois que uma ação for executada na camada da service.

Jobs

Essa camada é criada para armazenar tarefas agendadas que precisam ser feitas automaticamente em um certo intervalo de tempo. Como nossa regra de negócios está centralizada em um serviço isso facilita a utilização em um cron.

Devido a forma que o node funciona é melhor evitar a utilização de formas primitivas para agendar uma tarefa e com isso você ganha um controle melhor dos retornos da ação executada.

Models

Determina a estrutura lógica que representa uma entidade do banco de dados e da forma na qual os dados podem ser manipulados e organizados.

module.exports = (sequelize, DataTypes) => {
  const User = sequelize.define(
    'User',
    {
      name: DataTypes.STRING,
      email: DataTypes.STRING,
      password: DataTypes.STRING
    },
    {
      hooks: {
        beforeSave: async user => {
          if (user.password) {
            user.password = await bcrypt.hash(user.password, 10)
          }
        }
      }
    }
  )

  User.prototype.checkPassword = function (pass) {
    return bcrypt.compare(pass, this.password)
  }

  User.prototype.generateToken = function () {
    return jwt.sign({ id: this.id }, env.appSecret)
  }

  return User
}

Database

Onde organizamos nossas migrations para que a equipe tenha o controle do versionamento do banco de dados e dos seeders para popular nosso banco com dados inserido pelo desenvolvedor, para mais informação sobre essa parte é só ir nesse artigo abaixo: Migrations e Seeders no SequelizeJS Vamos começar com um overviewmedium.com

Utils

Trechos de código pequeno que são utilizado por mais de uma classe. Funções que são utilizadas para auxiliar na construção de um código maior e podendo ser utilizado em qualquer parte das camadas aplicação, por exemplo um helper pode utilizar mais de um util para construir um código mais completo para uma finalidade especifica.

const moment = require('moment');

module.exports = (date) => (date === undefined && !moment(date, moment.ISO_8601, true).isValid());

Helpers

Trechos de arquitetura de código que contém apresentações lógicas que podem ser compartilhadas entre as camadas da aplicação contendo várias funções englobada para servir de bootstrap a outros componentes e ergonomia do desenvolvedor.

const generateNumber = (max) => {
  let number = '';
  for (let index = 0; index < max; index++) {
    number = `${number}${faker.random.number(9)}`;
  }
  return number;
};

const minOne = (max) => {
  const number = faker.random.number(max);
  return number > 0 ? number : 1;
};

// CREATE USER
factory.define('Usuario', Usuario, {
  ...
});

// CREATE CLIENT
factory.define('Cliente', Cliente, {
  ...
});

// CREATE PARTY
factory.define('Party', Party, {
 ...
});

// CREATE LOCAL
factory.define('Local', Local, {
  ...
});

Constants

A utilização das constantes strings são muito importantes para você poder centralizar uma palavra de retorno de error, sucesso, status HTTP, nome de uma entidade que se repete por várias partes do código pois na hora quando houver uma mudança de valor naquela constantes todas as partes que utilizarem vão ser alteradas sem a necessidade de ficar procurando em cada arquivo pelo projeto.

module.exports = {
  userSuccess: 'Usuário foi criado com sucesso',
  userError: 'Usuário não pode ser criado',
  userNotFound: 'Usuário não encontrado',
  userAlreadyExist: 'Usuário já existe na nossa base de dados'
}

Config

É onde vamos centralizar todas as nossas variáveis de ambiente e outras configurações que utilizaremos pela aplicação, como: acesso a banco de dados, chave secreta, email, testes e muito mais..

const dotenv = require('dotenv');

dotenv.config({
  path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
});

module.exports = {
  secret: process.env.APP_SECRET,
  env: process.env.NODE_ENV,
  token: process.env.JWT_SECRET,
  tokenTest: process.env.JWT_TEST_TOKEN,
  database: {
    host: process.env.DB_HOST,
    user: process.env.DB_USER,
    pass: process.env.DB_PASS,
    name: process.env.DB_NAME,
    port: process.env.DB_PORT,
    dialect: process.env.DB_DIALECT,
  },
  sqlServer: {
    user: process.env.SQL_SERVER_USER,
    pwd: process.env.SQL_SERVER_PWD,
    host: process.env.SQL_SERVER_HOST,
    db: process.env.SQL_SERVER_DB,
  },
  salt: process.env.SALT_ROUNDS,
  databaseURL: process.env.DATABASE_URI,
  mail: {
    host: process.env.MAIL_HOST,
    port: process.env.MAIL_PORT,
    user: process.env.MAIL_USER,
    pass: process.env.MAIL_PASS,
  },
};

É parecido com o problema que temos com as constantes, imagine termos process.env.JWT_SECRET espalhados em models, tests, controllers e quando você tiver que alterar o nome da variavel ou algo do tipo por conta que mudou a forma como você cria um token e sair atualizando e caçando cada arquivo para fazer a alteração.

Rotas

Separamos as rotas das controllers, pois uma rota pode ter vários tipo de requisições (post, get, put, delete, option) e assim mantemos o código limpo.

const routes = require('express').Router()

const UserController = require('../app/controllers/UserController')
const AuthController = require('../app/controllers/AuthController')

// Authentication routes
routes.post('/signin', AuthController.store)

// User routes
routes.post('/', UserController.store)
routes.put('/:id', UserController.update)
routes.get('/:id', UserController.get)
routes.delete('/:id', UserController.destroy)

module.exports = routes

Esse padrão é perfeito para qualquer projeto?

Cada projeto é único e com isso temos que ter a flexibilidade para adequar com a necessidades que surgem, para essa organização já foi testada em produção correspondendo bem nos quesitos de escalabilidade e sustentabilidade do código e alinhando a agilidade com a qualidade na entrega dos resultados.