A arquitetura de software é um conceito fundamental na engenharia de software. A escolha de uma arquitetura apropriada pode ter um impacto significativo na qualidade, manutenção e escalabilidade de um sistema. Uma arquitetura popular que ganhou destaque nos últimos anos é a Clean Architecture (Arquitetura Limpa), proposta por Robert C. Martin, também conhecido como “Uncle Bob”. Neste artigo, vamos explorar a Clean Architecture aplicada a projetos reais utilizando Node.js e TypeScript.
O que é Clean Architecture?
A Clean Architecture é um estilo arquitetural que promove a separação de preocupações e a independência de frameworks e bibliotecas externas. Ela é baseada em princípios como a Inversão de Dependências (Dependency Inversion), Separação de Interesses (Separation of Concerns) e Princípio da Responsabilidade Única (Single Responsibility Principle).
Em resumo, a Clean Architecture busca criar um sistema modular, com componentes independentes e bem definidos, facilitando a manutenção, testabilidade e escalabilidade do código. Além disso, ela enfatiza a clareza e a legibilidade do código, tornando-o mais fácil de entender e evoluir ao longo do tempo.
Princípios da Clean Architecture
A Clean Architecture é composta por várias camadas, cada uma com um conjunto específico de responsabilidades. Vamos detalhar as principais camadas e seus princípios:
Entidades (Entities): Representam os objetos de negócio do sistema, como entidades de domínio, e são independentes de qualquer infraestrutura externa. Elas encapsulam as regras de negócio e contêm apenas a lógica de negócio pura.
Casos de Uso (Use Cases): Representam as ações que podem ser realizadas no sistema, como a execução de uma determinada funcionalidade. Eles orquestram a interação entre as entidades e as interfaces do sistema. Os Casos de Uso são dependentes das Entidades e são independentes das interfaces externas, como o banco de dados, a camada de apresentação e outros detalhes de implementação.
Interfaces (Interfaces): Representam os pontos de entrada e saída do sistema, como APIs, interfaces de usuário e serviços externos. Elas são responsáveis por adaptar os dados e as ações do sistema para a comunicação com o mundo externo. As Interfaces são dependentes dos Casos de Uso, mas não dependem das entidades.
Frameworks e Drivers (Frameworks and Drivers): Representam as tecnologias externas utilizadas pelo sistema, como frameworks web, bibliotecas de acesso a banco de dados, serviços de terceiros, entre outros. Eles são responsáveis por fornecer a infraestrutura necessária para a execução do sistema. Os Frameworks e Drivers são dependentes das Interfaces, mas não dependem das entidades ou dos casos de uso.
Exemplo de aplicação real.
Vamos considerar um cenário de uma aplicação de e-commerce, onde temos as seguintes funcionalidades: cadastro de usuários, cadastro de produtos, adição de produtos ao carrinho, realização de pedidos e processamento de pagamentos. Vamos assumir que a aplicação será desenvolvida utilizando Node.js e TypeScript.
Organização do Projeto Ao aplicar a Clean Architecture a esse projeto, podemos organizar o código em camadas, seguindo os princípios da arquitetura limpa. Aqui está uma possível estrutura de diretórios para esse projeto:
src/
|- entities/
|- User.ts
|- Product.ts
|- Cart.ts
|- Order.ts
|- Payment.ts
|- usecases/
|- user/
|- CreateUserUseCase.ts
|- GetUserUseCase.ts
|- product/
|- CreateProductUseCase.ts
|- GetProductUseCase.ts
|- cart/
|- AddToCartUseCase.ts
|- RemoveFromCartUseCase.ts
|- GetCartUseCase.ts
|- order/
|- CreateOrderUseCase.ts
|- GetOrderUseCase.ts
|- payment/
|- ProcessPaymentUseCase.ts
|- interfaces/
|- controllers/
|- UserController.ts
|- ProductController.ts
|- CartController.ts
|- OrderController.ts
|- PaymentController.ts
|- repositories/
|- UserRepository.ts
|- ProductRepository.ts
|- CartRepository.ts
|- OrderRepository.ts
|- PaymentRepository.ts
|- frameworks/
|- express/
|- routes/
|- userRoutes.ts
|- productRoutes.ts
|- cartRoutes.ts
|- orderRoutes.ts
|- paymentRoutes.ts
|- controllers/
|- ExpressUserController.ts
|- ExpressProductController.ts
|- ExpressCartController.ts
|- ExpressOrderController.ts
|- ExpressPaymentController.ts
|- typeorm/
|- repositories/
|- TypeORMUserRepository.ts
|- TypeORMProductRepository.ts
|- TypeORMCartRepository.ts
|- TypeORMOrderRepository.ts
|- TypeORMPaymentRepository.ts
|- externalServices/
|- paymentGateway.ts
Aqui, temos a pasta entities
, que contém as entidades de domínio do sistema, como User, Product, Cart, Order e Payment. A pasta usecases
contém os casos de uso do sistema, como CreateUserUseCase, GetProductUseCase, AddToCartUseCase, entre outros. A pasta interfaces
contém as interfaces de comunicação do sistema, como controllers e repositories, que serão implementadas pelas camadas externas do sistema. A pasta frameworks
contém a implementação dos detalhes de infraestrutura, como rotas e controllers específicos de uma biblioteca ou framework, como o Express ou o TypeORM.
Dependências e Fluxo de Dados Nesse exemplo, as entidades e os casos de uso não têm dependências externas, ou seja, são as camadas mais internas do sistema. Os casos de uso dependem das entidades, pois eles manipulam as regras de negócio do domínio. As interfaces, como os controllers e os repositories, dependem dos casos de uso, pois são responsáveis por adaptar as ações do sistema para a comunicação com o mundo externo. Por fim, os frameworks e drivers, como as rotas e controllers específicos do Express ou do TypeORM, dependem das interfaces para implementar a lógica de comunicação com o sistema.
O fluxo de dados ocorre de dentro para fora, ou seja, as informações fluem das camadas mais internas para as camadas mais externas. Por exemplo, quando um usuário faz uma requisição para criar um novo usuário na aplicação, a requisição é tratada pelo UserController
, que utiliza o CreateUserUseCase
para manipular a entidade User
e criar um novo usuário no sistema. O CreateUserUseCase
, por sua vez, pode utilizar o UserRepository
para persistir os dados do novo usuário em um banco de dados, por exemplo.
A estrutura da Clean Architecture é representada por um conjunto de círculos concêntricos, onde as camadas internas representam as partes mais centrais e estáveis do sistema, e as camadas externas representam as partes mais externas e instáveis do sistema. A ideia é que as dependências sempre apontem para dentro, ou seja, as camadas mais internas não dependem das camadas mais externas, criando um fluxo unidirecional
Os Princípios da Clean Architecture nesse exemplo
A Clean Architecture é baseada em uma série de princípios que auxiliam na criação de um código limpo, testável e de fácil manutenção. Alguns desses princípios são:
Separação de responsabilidades: cada componente do sistema deve ter uma única responsabilidade e não deve ter conhecimento detalhado sobre as implementações externas.
Inversão de dependências: as dependências devem ser invertidas, ou seja, as camadas internas não devem depender das camadas externas. Isso permite a substituição de implementações externas sem afetar as camadas internas.
Testabilidade: o código deve ser projetado de forma a ser facilmente testável, permitindo a escrita de testes unitários e de integração para garantir a qualidade do software.
Separação de preocupações: a lógica de negócio do sistema deve estar separada das implementações de infraestrutura, como bancos de dados e frameworks, permitindo a alteração dessas implementações sem afetar a lógica de negócio.
Benefícios da Clean Architecture
A aplicação da Clean Architecture em um projeto real traz diversos benefícios, tais como:
Manutenção facilitada: a separação clara de responsabilidades e a inversão de dependências tornam o código mais fácil de ser mantido, permitindo atualizações e correções de forma mais eficiente.
Testabilidade: a arquitetura limpa facilita a escrita de testes unitários e de integração, permitindo a identificação precoce de problemas e a garantia da qualidade do software.
Flexibilidade: a separação de preocupações e a inversão de dependências tornam o sistema mais flexível para adaptação a mudanças de requisitos ou substituição de tecnologias.
Escalabilidade: a arquitetura limpa permite que o sistema seja escalável, pois a separação de responsabilidades e a inversão de dependências permitem que novos recursos sejam adicionados sem afetar a estrutura existente.
Segundo Exemplo
Vamos agora exemplificar a aplicação da Clean Architecture em um projeto real de gerenciamento de tarefas (to-do list) usando Node.js e TypeScript. Para isso, vamos seguir a estrutura de camadas descrita anteriormente, utilizando entidades, casos de uso, interfaces e frameworks/drivers.
- Entidades As entidades são as representações dos objetos de domínio do sistema. No nosso exemplo, teríamos a entidade
Task
, que pode ser definida como uma classe em TypeScript com as propriedades e métodos relevantes para uma tarefa, como oid
,title
,description
,createdAt
,updatedAt
, e os métodos para realizar operações relacionadas a tarefas, como marcar como concluída, atualizar o título, etc.
class Task {
private id: number;
private title: string;
private description: string;
private createdAt: Date;
private updatedAt: Date;
private isCompleted: boolean;
// Construtor
constructor(id: number, title: string, description: string) {
this.id = id;
this.title = title;
this.description = description;
this.createdAt = new Date();
this.updatedAt = new Date();
this.isCompleted = false;
}
// Getters e Setters
// ...
// Métodos
markAsCompleted(): void {
this.isCompleted = true;
this.updatedAt = new Date();
}
updateTitle(newTitle: string): void {
this.title = newTitle;
this.updatedAt = new Date();
}
// Outros métodos relevantes
// ...
}
- Casos de Uso Os casos de uso são responsáveis por implementar a lógica de negócio do sistema. No nosso exemplo, teríamos os seguintes casos de uso:
CreateTaskUseCase
: responsável por criar uma nova tarefa no sistema.GetActiveTasksUseCase
: responsável por obter todas as tarefas ativas do usuário.MarkTaskAsCompletedUseCase
: responsável por marcar uma tarefa como concluída.UpdateTaskTitleUseCase
: responsável por atualizar o título de uma tarefa.
Esses casos de uso dependem das entidades para realizar as operações necessárias. Eles podem ser implementados como classes em TypeScript, e cada um deles teria um método executável que implementa a lógica de negócio específica.
class CreateTaskUseCase {
private taskRepository: TaskRepository;
constructor(taskRepository: TaskRepository) {
this.taskRepository = taskRepository;
}
async execute(title: string, description: string): Promise {
const task = new Task(1, title, description); // Simulação de criação de uma nova tarefa com id 1
return this.taskRepository.createTask(task);
}
}
class GetActiveTasksUseCase {
private taskRepository: TaskRepository;
constructor(taskRepository: TaskRepository) {
this.taskRepository = taskRepository;
}
async execute(): Promise {
return this.taskRepository.getActiveTasks();
}
}
class MarkTaskAsCompletedUseCase {
private taskRepository: TaskRepository;
constructor(taskRepository: TaskRepository) {
this.taskRepository = taskRepository;
}
async execute(taskId: number): Promise {
const task = await this.taskRepository.getTaskById(taskId);
task.markAsCompleted();
return this.taskRepository.updateTask(task);
}
}
class UpdateTaskTitleUseCase {
private taskRepository: TaskRepository;
constructor(taskRepository: TaskRepository) {
this.taskRepository = taskRepository;
}
async execute(taskId: number, newTitle: string): Promise {
const task = await this.taskRepository.getTaskById(taskId);
task.updateTitle(newTitle);
return this.taskRepository.updateTask(task);
}
}
- Interfaces As interfaces são responsáveis por definir os contratos que os elementos externos ao domínio do sistema (como serviços externos, bancos de dados, etc.) devem cumprir. No nosso exemplo, teríamos as seguintes interfaces:
TaskRepository
: define os métodos que o repositório de tarefas deve implementar para realizar operações de CRUD (Create, Read, Update, Delete) no banco de dados ou em outra forma de armazenamento persistente.
interface TaskRepository {
createTask(task: Task): Promise;
getTaskById(taskId: number): Promise;
getActiveTasks(): Promise;
updateTask(task: Task): Promise;
deleteTask(taskId: number): Promise;
}
- Frameworks/Drivers Os frameworks/drivers são responsáveis por implementar os detalhes técnicos do sistema, como as interfaces com o banco de dados, a interface de usuário, etc. No nosso exemplo, poderíamos ter os seguintes frameworks/drivers:
TaskRepositoryDB
: uma implementação concreta da interfaceTaskRepository
que realiza operações de CRUD no banco de dados. Poderia ser implementada usando um ORM (Object-Relational Mapping) como TypeORM ou Sequelize, por exemplo.
class TaskRepositoryDB implements TaskRepository {
// Implementação dos métodos da interface TaskRepository usando um ORM, por exemplo
// ...
}
TaskRepositoryMemory
: uma implementação concreta da interfaceTaskRepository
que realiza operações de CRUD em memória, sem persistência no banco de dados. Poderia ser usada para fins de testes ou desenvolvimento.
class TaskRepositoryMemory implements TaskRepository {
private tasks: Task[];
constructor() {
this.tasks = [];
}
async createTask(task: Task): Promise {
this.tasks.push(task);
return task;
}
async getTaskById(taskId: number): Promise {
const task = this.tasks.find(t => t.getId() === taskId);
return task ? task : null;
}
async getActiveTasks(): Promise {
return this.tasks.filter(t => !t.isCompleted());
}
async updateTask(task: Task): Promise {
const index = this.tasks.findIndex(t => t.getId() === task.getId());
if (index !== -1) {
this.tasks[index] = task;
return task;
} else {
throw new Error('Task not found');
}
}
async deleteTask(taskId: number): Promise {
this.tasks = this.tasks.filter(t => t.getId() !== taskId);
}
}
5. No exemplo anterior, temos uma dependência unidirecional entre as camadas, onde a camada de uso (Use Cases) depende da interface (contrato) da camada de interface (Interfaces), e a camada de interface depende da implementação concreta da camada de Frameworks/Drivers. Essa abordagem permite que a camada de uso seja isolada de detalhes de implementação técnica, tornando-a independente de frameworks, bancos de dados ou outras tecnologias específicas.
Além disso, é importante respeitar a direção da dependência, que sempre vai de dentro para fora. Ou seja, as camadas mais internas (domínio) não devem depender das camadas mais externas (interfaces e frameworks/drivers). Isso permite uma maior flexibilidade e manutenibilidade do sistema, pois é mais fácil substituir ou modificar as camadas externas sem afetar o domínio do sistema.
Outro princípio importante da arquitetura limpa é a inversão de dependência (Dependency Inversion), que consiste em depender de abstrações (interfaces e contratos) em vez de depender de implementações concretas. Isso facilita a substituição de implementações e a realização de testes unitários, uma vez que é possível substituir as implementações concretas por mocks ou stubs durante os testes.
- Exemplo de aplicação real com Clean Architecture em Node.js e TypeScript Vamos agora exemplificar como a arquitetura limpa pode ser aplicada em um projeto real em Node.js e TypeScript. Vamos supor que estamos desenvolvendo uma aplicação de lista de tarefas (to-do list) e queremos aplicar os conceitos de Clean Architecture.
my-todo-app/
|-- src/
| |-- domain/
| | |-- models/
| | | |-- Task.ts
| | |-- repositories/
| | | |-- TaskRepository.ts
| | |-- useCases/
| | | |-- CreateTaskUseCase.ts
| | | |-- GetTaskByIdUseCase.ts
| | | |-- GetActiveTasksUseCase.ts
| | | |-- MarkTaskAsCompletedUseCase.ts
| | | |-- UpdateTaskTitleUseCase.ts
| |-- interfaces/
| | |-- controllers/
| | | |-- TaskController.ts
| | |-- repositories/
| | | |-- TaskRepositoryDB.ts
| | |-- routes/
| | | |-- taskRoutes.ts
| |-- frameworks/
| | |-- database/
| | | |-- index.ts
| | |-- express/
| | | |-- server.ts
| | | |-- middlewares/
| | | | |-- errorMiddleware.ts
| | | | |-- validationMiddleware.ts
|-- tests/
| |-- domain/
| | |-- models/
| | | |-- Task.test.ts
| |-- interfaces/
| | |-- repositories/
| | | |-- TaskRepositoryDB.test.ts
| | |-- routes/
| | | |-- taskRoutes.test.ts
Na pasta
src/domain
ficam os modelos de domínio, como a entidadeTask
, que representa uma tarefa na aplicação.Na pasta
src/domain/repositories
ficam as interfaces dos repositórios, como oTaskRepository
, que define os métodos para realizar operações relacionadas a tarefas, como criar, atualizar, buscar e marcar como concluída.Na pasta
src/domain/useCases
ficam os casos de uso da aplicação, comoCreateTaskUseCase
,GetTaskByIdUseCase
,GetActiveTasksUseCase
e outros, que encapsulam as regras de negócio relacionadas às operações de tarefas.Na pasta
src/interfaces/controllers
ficam os controladores da aplicação, como oTaskController
, que é responsável por receber as requisições HTTP e chamar os casos de uso correspondentes.Na pasta
src/interfaces/repositories
ficam as implementações concretas dos repositórios, como oTaskRepositoryDB
, que é responsável por implementar a interfaceTaskRepository
e realizar as operações de acesso a banco de dados.Na pasta
src/interfaces/routes
ficam as rotas da aplicação, como otaskRoutes
, que define as rotas HTTP relacionadas a tarefas e faz a chamada aos controladores correspondentes.Na pasta
src/frameworks/database
ficam as configurações e conexões com o banco de dados, como o arquivoindex.ts
, que é responsável por criar a conexão com o banco de dados.Na pasta
src/frameworks/express
ficam as configurações e middlewares do framework Express, como o arquivoserver.ts
, que é responsável por configurar o servidor HTTP e os middlewares, como o middleware de tratamento de erros e validação.Na pasta
tests/domain
ficam os testes unitários para os modelos de domínio, como oTask.test.ts
, que testa as funcionalidades da entidadeTask
.Na pasta
tests/interfaces/repositories
ficam os testes unitários para as implementações dos repositórios, como oTaskRepositoryDB.test.ts
, que testa as operações de acesso a banco de dados doTaskRepositoryDB
.Na pasta
tests/interfaces/routes
ficam os testes de integração para as rotas da aplicação, como otaskRoutes.test.ts
, que testa as rotas HTTP relacionadas a tarefas e suas interações com os controladores e casos de uso.
Essa é apenas uma estrutura de exemplo e a organização pode variar de acordo com as necessidades e preferências de cada projeto. O importante é seguir os princípios e conceitos da arquitetura limpa, mantendo a separação de responsabilidades entre as camadas e permitindo a substituição de implementações sem afetar o domínio da aplicação.
Espero que isso tenha fornecido uma compreensão clara de como aplicar a Clean Architecture em um projeto real usando Node.js e TypeScript. A arquitetura limpa é uma abordagem poderosa para desenvolvimento de software, que promove a manutenibilidade, flexibilidade e testabilidade do código, permitindo criar sistemas de software robustos e escaláveis.
Conclusão
A Clean Architecture é uma abordagem de desenvolvimento de software que promove a criação de código limpo, testável e de fácil manutenção, através da separação clara de responsabilidades, inversão de dependências e foco na lógica de negócio do sistema. Com a utilização de conceitos como entidades, casos de uso, interfaces e frameworks/drivers, é possível criar sistemas flexíveis, escaláveis e de alta qualidade.
Para exemplificar a aplicação da Clean Architecture em projetos reais com Node.js e TypeScript, vamos considerar um cenário hipotético de uma aplicação de gerenciamento de tarefas (to-do list). Vamos supor que a aplicação precisa permitir que os usuários criem tarefas, visualizem suas tarefas ativas e concluam tarefas já realizadas.
A seguir, podemos descrever como as diferentes camadas da Clean Architecture seriam aplicadas nesse cenário:
Entidades: As entidades representariam os objetos de domínio do sistema, como a própria tarefa. A entidade
Task
poderia ter propriedades comoid
,title
,description
,createdAt
,updatedAt
, etc., e métodos para realizar operações relacionadas a tarefas, como marcar como concluída, atualizar o título, etc.Casos de Uso: Os casos de uso seriam responsáveis pela manipulação da lógica de negócio do sistema. Por exemplo, teríamos um caso de uso
CreateTaskUseCase
que seria responsável por criar uma nova tarefa no sistema, um caso de usoGetActiveTasksUseCase
que seria responsável por obter todas as tarefas ativas do usuário, e assim por diante. Esses casos de uso dependeriam das entidades para realizar as operações necessárias.Interfaces: As interfaces seriam responsáveis por adaptar a comunicação do sistema com o mundo externo. Por exemplo, teríamos um
TaskController
que receberia as requisições HTTP relacionadas a tarefas, utilizaria os casos de uso apropriados para tratar as requisições e retornar as respostas adequadas. OTaskController
dependeria dos casos de uso para realizar as operações necessárias.Frameworks/Drivers: Os frameworks e drivers seriam responsáveis por implementar a comunicação do sistema com a infraestrutura externa, como banco de dados, APIs externas, etc. Por exemplo, teríamos um
TaskRepository
que seria responsável por persistir as tarefas no banco de dados. Esse repositório implementaria uma interface definida nos casos de uso, permitindo a substituição do banco de dados sem afetar a lógica de negócio.
Essa é apenas uma abordagem básica de como a Clean Architecture pode ser aplicada em uma aplicação real com Node.js e TypeScript. É importante ressaltar que a implementação detalhada pode variar de acordo com as necessidades e requisitos específicos do projeto. O importante é seguir os princípios da Clean Architecture, como a separação de responsabilidades, inversão de dependências e testabilidade, para criar um código limpo, organizado e de fácil manutenção.
Espero que este artigo tenha proporcionado uma visão geral sobre a aplicação da Clean Architecture em projetos reais usando Node.js e TypeScript, e que possa ser útil para sua compreensão e aplicação em seus próprios projetos. Lembre-se de estudar a fundo os conceitos e princípios da Clean Architecture e adaptá-los às necessidades específicas do seu projeto, sempre buscando aprimorar a qualidade e manutenibilidade do código.