A arquitetura limpa (clean architecture) é um padrão de arquitetura de software que promove a separação de preocupações (SoC), o isolamento de tecnologias e a independência da camada de infraestrutura. Essa abordagem é comumente usada em aplicativos empresariais complexos, pois ajuda a garantir a escalabilidade, a manutenibilidade e a testabilidade do código.
Neste artigo, exploraremos como aplicar a arquitetura limpa ao desenvolvimento de aplicativos Node.js usando TypeScript, com ênfase na segurança. Vamos começar definindo a arquitetura limpa e, em seguida, discutiremos como aplicá-la à nossa pilha tecnológica.
Clean Architecture
A arquitetura limpa é baseada no princípio da inversão de dependência (IoC). Em vez de permitir que as camadas de alto nível dependam das camadas de baixo nível, a arquitetura limpa coloca a lógica de negócios no centro do design do software, criando um anel externo de interfaces de entrada e um anel interno de interfaces de saída.
A seguir, vamos definir as camadas da arquitetura limpa:
Entidades: As entidades são objetos que representam conceitos de negócios em um domínio específico. Elas encapsulam as regras de negócio e são independentes da tecnologia.
Casos de uso: Os casos de uso são a lógica de negócio da aplicação e dependem das entidades. Eles são responsáveis por orquestrar as operações necessárias para atingir os objetivos do usuário.
Adaptadores: Os adaptadores são responsáveis por converter os dados em formato que possa ser usado pela camada de negócios. Eles incluem interfaces de entrada (por exemplo, API REST) e interfaces de saída (por exemplo, acesso a banco de dados).
Infraestrutura: A camada de infraestrutura é responsável por lidar com as tecnologias específicas, como banco de dados, rede e sistema de arquivos.
A seguir, vamos definir algumas diretrizes para aplicar a arquitetura limpa no desenvolvimento de aplicativos Node.js usando TypeScript.
Diretrizes
Separar as camadas: Como mencionado anteriormente, a arquitetura limpa enfatiza a separação de preocupações. É importante separar as camadas de negócio, adaptadores e infraestrutura em módulos distintos e independentes.
Usar interfaces: A arquitetura limpa enfatiza o uso de interfaces para definir as dependências entre as camadas. Isso torna mais fácil substituir as implementações de uma camada por outra.
Isolar a lógica de negócio: A camada de negócio deve estar completamente isolada da tecnologia usada na camada de infraestrutura. Isso torna mais fácil testar e manter o código.
Validar entradas e saídas: Como a segurança é uma preocupação crítica em muitas aplicações, é importante validar todas as entradas e saídas do aplicativo para garantir que elas estejam de acordo com as expectativas.
Tratar erros corretamente: O tratamento de erros é uma parte crucial da segurança em qualquer aplicativo. A arquitetura limpa promove a separação do tratamento de erros da lógica de negócios. Isso torna mais fácil lidar com exceções e evitar vazamentos de informações sensíveis.
Usar criptografia: A arquitetura limpa não fornece uma solução específica para a criptografia de dados, mas recomenda o uso de bibliotecas criptográficas confiáveis e de padrões de segurança reconhecidos.
Limitar o acesso aos recursos: A camada de infraestrutura deve ser configurada de forma a limitar o acesso aos recursos, como bancos de dados e arquivos. Isso inclui a aplicação de autenticação e autorização adequadas.
Testar com cobertura completa: A arquitetura limpa promove a testabilidade do código e recomenda o uso de testes automatizados. É importante ter uma cobertura de teste completa para garantir que todos os cenários possíveis tenham sido testados.
Aplicando a arquitetura limpa com Node.js e TypeScript
A seguir, vamos mostrar um exemplo de como aplicar a arquitetura limpa ao desenvolvimento de um aplicativo Node.js usando TypeScript.
Suponha que estamos desenvolvendo um sistema de autenticação que permita que os usuários se registrem e façam login. Vamos começar definindo as entidades do nosso domínio:
// user.entity.ts
export interface User {
id: string;
email: string;
password: string;
}
Em seguida, vamos definir um caso de uso que permita que os usuários se registrem:
// register.usecase.ts
import { User } from './user.entity';
export interface RegisterUserRequest {
email: string;
password: string;
}
export interface RegisterUserResponse {
user: User;
}
export interface RegisterUserUseCase {
execute(request: RegisterUserRequest): Promise;
}
A seguir, vamos definir um adaptador de interface de entrada que permite que os usuários se registrem por meio de uma API REST:
// register.controller.ts
import { Request, Response } from 'express';
import { RegisterUserUseCase, RegisterUserRequest } from '../usecases/register.usecase';
export class RegisterUserController {
constructor(private readonly registerUserUseCase: RegisterUserUseCase) {}
async handle(request: Request, response: Response) {
const registerUserRequest: RegisterUserRequest = {
email: request.body.email,
password: request.body.password,
};
const registerUserResponse = await this.registerUserUseCase.execute(registerUserRequest);
response.json(registerUserResponse);
}
}
Em seguida, vamos definir um adaptador de interface de saída que permite que os usuários sejam armazenados em um banco de dados MongoDB:
// user.repository.ts
import { User } from './user.entity';
export interface UserRepository {
save(user: User): Promise;
findByEmail(email: string): Promise;
}
A seguir, vamos implementar o caso de uso e os adaptadores:
// register.usecase.impl.ts
import { User } from '../entities/user.entity';
import { RegisterUserRequest, RegisterUserResponse, RegisterUserUseCase } from './register.usecase';
import { UserRepository } from '../entities/user.repository';
export class RegisterUserUseCaseImpl implements RegisterUser {
constructor(private readonly userRepository: UserRepository) {}
async execute(request: RegisterUserRequest): Promise {
const existingUser = await this.userRepository.findByEmail(request.email);
}
Conclusão
aqui está uma versão mais clara e simplificada do caso de uso usando Clean Architecture, Node.js e TypeScript:
// register.usecase.ts
import { v4 as uuidv4 } from 'uuid';
import { User } from '../entities/user.entity';
import { UserRepository } from '../repositories/user.repository';
export interface RegisterUserRequest {
email: string;
password: string;
}
export interface RegisterUserResponse {
user: User;
}
export class RegisterUserUseCase {
constructor(private readonly userRepository: UserRepository) {}
async execute(request: RegisterUserRequest): Promise {
const existingUser = await this.userRepository.findByEmail(request.email);
if (existingUser) {
throw new Error('User already exists');
}
const user: User = {
id: uuidv4(),
email: request.email,
password: request.password,
};
await this.userRepository.save(user);
return { user };
}
}
Neste caso de uso, temos uma classe RegisterUserUseCase
que recebe um objeto UserRepository
em seu construtor. A classe RegisterUserUseCase
possui um método execute
que recebe um objeto RegisterUserRequest
contendo o email e a senha do usuário a ser registrado.
Dentro do método execute
, a primeira coisa que fazemos é verificar se o usuário já existe no repositório, usando o método findByEmail
do UserRepository
. Se o usuário já existir, lançamos um erro.
Caso contrário, criamos um novo objeto User
, gerando um novo ID usando a biblioteca uuid
, e salvamos o novo usuário no repositório, usando o método save
do UserRepository
.
Por fim, retornamos um objeto RegisterUserResponse
contendo o usuário recém-criado.
Essa implementação segue os princípios do Clean Architecture, separando as camadas da aplicação e definindo interfaces claras para as dependências externas, permitindo uma maior flexibilidade e manutenibilidade do código.