Conteúdos
  1. 1. Separação de conceitos e responsabilidades
  2. 2. Arquitetura
    1. 2.1. Camada de domínio
    2. 2.2. Camada de aplicação
    3. 2.3. Camada de infraestrutura
    4. 2.4. Camada de interfaces de entrada
  3. 3. NodeJS e separação de conceitos
    1. 3.1. NodeJS e a camada de domínio
    2. 3.2. NodeJS e a camada de aplicação
    3. 3.3. NodeJS e a camada de infraestrutura
    4. 3.4. NodeJS e a camada de interfaces de entrada
    5. 3.5. Encaixe entre as camadas
  4. 4. Exemplo prático
  5. 5. Informação adicional

Softwares tendem a mudar o tempo todo, e uma coisa que pode definir o quão bom é um código é justamente a facilidade que se tem para alterá-lo. Mas o que torna um código fácil de se dar manutenção?

…se você tem medo de mudar alguma coisa, ela está claramente mal projetada. - Martin Fowler

Separação de conceitos e responsabilidades

Junte as unidades que mudam pelo mesmo motivo, separe as que mudam por motivos diferentes. Seja esta unidade uma função, uma classe ou um módulo, este são o princípio da responsabilidade única e a separação de conceitos.

Projetar software que se baseia no Single responsibility principle (ou SRP) e na Separation of concerns (ou SoC) de modo que alterá-lo seja fácil começa na arquitetura.

Arquitetura

Uma responsabilidade em desenvolvimento de software é uma tarefa a qual uma unidade se propõe a realizar: representar o conceito de “produto” na sua aplicação, receber requisições da rede, salvar um usuário no banco de dados, entre outros. Reparou que nestes três exemplos as responsabilidades não parecem nem pertencer a uma mesma categoria? Isso se deve ao fato de elas serem responsabilidades de camadas diferentes, onde cada camada também pode ser dividida em conceitos. Seguindo o exemplo, a responsabilidade “salvar um usuário no banco de dados” se encaixa na camada que se comunica com o banco, dentro do conceito de usuário.

De maneira geral, as arquiteturas que aplicam os conceitos acima tendem a separar o código em 4 camadas:

Camada de domínio

Nesta camada você definirá as unidades que se relacionam diretamente com o seu domínio, onde representarão entidades do domínio e suas regras de negócio, por exemplo, numa aplicação que contém usuários e times teríamos: a classe User, a classe JoinTeamPolicy que controla se certo usuário pode fazer parte de um certo time, entre outros. Esta é a camada mais isolada e importante do seu software, e será utilizada pela camada de aplicação para definir os casos de uso.

Camada de aplicação

A camada de aplicação realizará a interação entre as unidades da camada de domínio para definir o comportamento da sua aplicação que não pertence diretamente ao seu domínio, incluindo os casos de uso, como uma classe JoinTeam que recebe uma instância de User e uma de Team como parâmetro, e utiliza a classe JoinTeamPolicy para checar essa possibilidade.

A camada de aplicação também serve de adapter para a camada de infraestrutura. Digamos que em sua aplicação é possível enviar emails. A classe que se comunica diretamente com o servidor de email (chamaremos de MailChimpService) pertencerá à camada de infraestrutura, já a classe que será usada pela camada de aplicação para enviar emails pertencerá à camada de aplicação e será chamada de EmailService, que utilizará o MailChimpService internamente. Desta forma toda à sua aplicação, com exceção do EmailService, não precisará saber detalhes sobre o serviço de email utilizado, apenas que a classe EmailService envia emails.

Camada de infraestrutura

É a mais baixa das camadas, que se comunicará diretamente com o que está externo à sua aplicação, como o banco de dados, serviços de email e sistemas de fila.

Uma característica de aplicações multicamada é a utilização do repository pattern para a comunicação com o banco de dados (ou algum outro serviço externo de persistência, como uma API). O repository será um objeto que será tratado mais ou menos como uma coleção, e as camadas que o utilizarão (as de domínio e de aplicação) não precisarão saber qual tecnologia de persistência está sendo usado (semelhante ao exemplo sobre serviços de email). A ideia é que a interface a ser implementada pelo repository pertença à camada de domínio (ou seja, a camada de domínio apenas saberá os métodos que o repository terá e quais parâmetros eles aceitam) e a implementação desta interface esteja na camada de infraestrutura, isto torna ambas as camadas mais flexíveis, inclusive na hora de testar. Como em JavaScript não há o conceito de interface, apenas imagina-se uma e cria-se a implementação na camada de infraestrutura normalmente.

Camada de interfaces de entrada

Esta camada contém todos os pontos de entrada da sua aplicação, como controllers, a CLI, websockets, interface gráfica (caso não seja uma aplicação web). Na camada de interfaces de entrada não se deve haver nenhum conhecimento sobre as regras de negócio, casos de uso, tecnologias de persistência, nem nenhuma outra lógica, apenas a passagem dos dados de entrada (como parâmetros de uma URL) para um caso de uso da camada de aplicação e a devolução da resposta da mesma para o usuário.

NodeJS e separação de conceitos

Ok, mas depois de toda essa teoria, como isso ficaria em uma aplicação Node? A verdade é que alguns padrões usados em arquiteturas multicamadas se encaixam muito bem com padrões usados no mundo JavaScript!

NodeJS e a camada de domínio

A camada de domínio no Node pode ser composta por simples classes ES6, mas existem diversas bibliotecas que você pode usar tanto com ES6 quanto ES5 para a criação de entidades de domínio, como: Structure, Ampersand State, tcomb e ObjectModel.

Veja um exemplo simples utilizando o Structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { attributes } = require('structure');
const User = attributes({
id: Number,
name: {
type: String,
required: true
},
age: Number
})(class User {
isLegal() {
return this.age >= User.MIN_LEGAL_AGE;
}
});
User.MIN_LEGAL_AGE = 21;

Repare que não incluí nesta lista os models do Backbone, nem bibliotecas como Sequelize e Mongoose, pois essas bibliotecas devem ser usadas na camada de infraestrutura para a comunicação com o exterior, e o resto da aplicação não precisa saber que estão sendo usadas.

NodeJS e a camada de aplicação

Os casos de uso pertencem à camada de aplicação e podem ter mais do que apenas “sucesso” e “falha” como resultado (diferentemente das promises). Um padrão utilizado com o Node que funciona bem para casos assim são os event emitters. Você pode fazer com que a classe do seu caso de uso estenda EventEmitter e emitir eventos para cada um dos resultados. Desta maneira no seu controller você só precisará executar o caso de uso e adicionar listeners pelos resultados, assim:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// CreateUser.js
const EventEmitter = require('events');
class CreateUser extends EventEmitter {
constructor({ usersRepository }) {
super();
this.usersRepository = usersRepository;
}
execute(userData) {
const user = new User(userData);
this.usersRepository
.add(user)
.then((newUser) => {
this.emit('SUCCESS', newUser);
})
.catch((error) => {
if(error.message === 'ValidationError') {
return this.emit('VALIDATION_ERROR', error);
}
this.emit('ERROR', error);
});
}
}

O caso de uso realiza a separação dos dois tipos de erro que vem do repository, mas quem usa o caso de uso não precisa (nem deve) saber da existência do usersRepository.

Desta forma, no seu controller você terá algo assim:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// UsersController
const UsersController = {
create(req, res) {
// Leia abaixo sobre a parte de infraestrutura e injeção
// de dependência para entender como esta instância será criada.
const createUser = new CreateUser({ usersRepository });
createUser
.on('SUCCESS', (user) => {
res.status(201).json(user);
})
.on('VALIDATION_ERROR', (error) => {
res.status(400).json({
type: 'ValidationError',
details: error.details
});
})
.on('ERROR', (error) => {
res.sendStatus(500);
});
createUser.execute(req.body.user);
}
};

NodeJS e a camada de infraestrutura

A implementação da camada de infraestrutura costuma não apresentar grandes dificuldades, só deve-se tomar cuidado para não vazar lógica desta camada para as superiores. Você pode, por exemplo, usar models do Sequelize para fazer a implementação de um repository que se comunica com um banco SQL, e dar ao repository métodos com nomes que não implicam a existência do SQL internamente, como o método add citado acima. Assim, criaremos implementação de um SequelizeUsersRepositoryque será passado para quem depende do repository de usuários apenas como usersRepository que obedece a interface imaginária UsersRepository, de modo a não se saber da existência do Sequelize nas outras camadas. Segue um exemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SequelizeUsersRepository.js
class SequelizeUsersRepository {
add(user) {
const { valid, errors } = user.validate();
if(!valid) {
const error = new Error('ValidationError');
error.details = errors;
return Promise.reject(error);
}
return UserModel
.create(user.attributes)
.then((dbUser) => dbUser.dataValues);
}
}

A ideia é a mesma para bancos de dados não relacionais, serviços de email, serviços de fila, APIs externas e afins.

NodeJS e a camada de interfaces de entrada

Para esta camada existem várias possibilidades de implementação com Node. Para a entrada de requisições HTTP o Express é o pacote mais usado, mas você pode usar também o Hapi ou o Restify. No fim das contas esta escolha é só um detalhe de implementação, e mudá-la não deve afetar nenhuma das outras camadas (se migrar de Express para Hapi, por exemplo, causou mudanças em outras camadas da sua aplicação, é sinal de que há acoplamento entre elas!).

Encaixe entre as camadas

Fazer com que uma camada se comunique diretamente com outra também pode ser ruim e causar acoplamento. Uma solução comum para este problema, não só no Node, é a utilização de dependency injection. Esta técnica consiste em fazer que toda dependência de uma classe seja recebida no seu construtor em vez de criada pela própria instância, criando o que se chama de inversion of control.

Utilizando esta técnica você consegue isolar bem as dependências de uma classe, tornando a mais flexível e bem mais fácil de ser testada pois mockar as dependências se torna algo trivial.

Para o Node existe um ótimo pacote para depedency injection chamado Awilix, que permite que você utilize a técnica sem acoplar seu código à ferramenta, e não sentir que está usando aquele estranho mecanismo de dependency injection do Angular 1, por exemplo. O autor do Awilix tem uma ótima série de artigos sobre dependency injection com Node e introdução à como usar o Awilix que vale a pena conferir. Se você está usando o Express, pode ser interessante também usar o Awilix-Express.

Exemplo prático

Mesmo com todos estes exemplos e explicações sobre camadas e conceitos acima, acredito que nada melhor do que um exemplo prático de uma aplicação utilizando uma arquitetura multicamadas para te convencer de que pode ser simples de usá-la. Por isso, criei um boilerplate para web APIs com Node que aplica arquitetura multicamadas já com o básico necessário configurado, inclusive todo documentado (na medida do possível para uma primeira versão) para que você possa praticar e até usar para o kickstart de uma aplicação web com Node, o boilerplate já está pronto pra ser usado em produção!

Informação adicional

Conteúdos
  1. 1. Separação de conceitos e responsabilidades
  2. 2. Arquitetura
    1. 2.1. Camada de domínio
    2. 2.2. Camada de aplicação
    3. 2.3. Camada de infraestrutura
    4. 2.4. Camada de interfaces de entrada
  3. 3. NodeJS e separação de conceitos
    1. 3.1. NodeJS e a camada de domínio
    2. 3.2. NodeJS e a camada de aplicação
    3. 3.3. NodeJS e a camada de infraestrutura
    4. 3.4. NodeJS e a camada de interfaces de entrada
    5. 3.5. Encaixe entre as camadas
  4. 4. Exemplo prático
  5. 5. Informação adicional