Dominando o Desenvolvimento de Software: Explorando Clean Code, SOLID e Clean Architecture

Neste artigo, vamos mergulhar nessas metodologias, explorar suas interconexões e demonstrar como sua aplicação conjunta pode capacitar os desenvolvedores a criar sistemas robustos e adaptáveis, que transcendem as exigências passageiras e perduram ao longo do tempo.
William Simionatto Nepomuceno | 9 de janeiro de 2024

Em um mundo onde a inovação e a velocidade são essenciais, a base de um desenvolvimento de software sólido e bem-sucedido é mais crucial do que nunca. Clean Code, os princípios SOLID e Clean Architecture são os alicerces sobre os quais os aplicativos de qualidade são construídos. Neste artigo, vamos mergulhar nessas metodologias, explorar suas interconexões e demonstrar como sua aplicação conjunta pode capacitar os desenvolvedores a criar sistemas robustos e adaptáveis, que transcendem as exigências passageiras e perduram ao longo do tempo. Essas abordagens não apenas atendem às demandas atuais, mas também têm a resiliência para evoluir com as mudanças do mercado e as evoluções tecnológicas. Ao compreender e aplicar esses princípios, os desenvolvedores estão investindo em um futuro em que o software é mais do que uma solução momentânea, é um ativo duradouro que resiste às provações do tempo e das transformações do cenário tecnológico.

1. Clean Code e Princípios SOLID

Nesta seção, iremos falar sobre os princípios essenciais do Clean Code e dos princípios SOLID. Veremos como esses dois conceitos se entrelaçam para criar uma base sólida para o desenvolvimento de softwares de alta qualidade.

1.1 Clean Code

O desenvolvimento de software eficaz não se trata apenas de criar uma funcionalidade que funcione corretamente, mas também de criar código que seja compreensível, flexível e sustentável ao longo do tempo. Clean Code, ou “Código Limpo”, refere-se à prática de escrever código de maneira clara e organizada, facilitando sua leitura, manutenção e colaboração entre os membros da equipe de desenvolvimento. Um código limpo não apenas entrega resultados, mas também promove um ambiente propício para a evolução contínua do software. Estes são alguns princípios e diretrizes essenciais para alcançar o Clean Code:

Nomes Significativos

Use nomes descritivos e significativos para variáveis, funções e classes. Isso torna o propósito do código mais claro.

// Exemplo com nome não significativo
function calcular(x: number, y: number): number {
return x * y;
}
// Exemplo com nome significativo:
function calcularProduto(numero1: number, numero2: number): number {
return numero1 * numero2;
}

Funções Pequenas e Específicas

Mantenha suas funções curtas e focadas em uma única responsabilidade. Isso torna mais fácil entender o que a função faz e ajuda na reutilização do código. Evite funções longas que fazem várias coisas.

// Exemplo de função longa que faz várias coisas:
function processarPedido(pedido: Pedido) {
// Validação do pedido
// Cálculos de preço
// Atualização do estoque
// Geração de nota fiscal
}
// Exemplo de funções pequenas e especificas:
function validarPedido(pedido: Pedido) {
// Validação do pedido
}
function calcularPreco(pedido: Pedido) {
// Cálculos de preço
}
function atualizarEstoque(pedido: Pedido) {
// Atualização do estoque
}
function gerarNotaFiscal(pedido: Pedido) {
// Geração de nota fiscal
}

Comentários Significativos

Use comentários quando necessário, mas evite comentários óbvios. Em vez disso, use comentários para explicar o “porquê” do código, em vez do “como”.

/ Comentário redundante:
function dobrarNumero(numero: number) {
// Esta função dobra o número.
return numero * 2;
}
// O código é autoexplicativo; o comentário é redundante
function dobrarNumero(numero: number) {
return numero * 2;
}

1.2 Princípios SOLID

Os Princípios SOLID são um conjunto de diretrizes de design de software que foram desenvolvidas para promover a criação de código robusto, flexível e de fácil manutenção. Eles foram introduzidos por Robert C. Martin, um renomado engenheiro de software, como um guia para ajudar os desenvolvedores a escrever código orientado a objetos de alta qualidade. O acrônimo SOLID representa cinco princípios distintos, cada um com o objetivo de abordar diferentes aspectos do design de software. Quando aplicados corretamente, esses princípios podem resultar em sistemas de software mais estáveis, extensíveis e fáceis de modificar, economizando tempo e recursos no ciclo de desenvolvimento e manutenção de software. Neste contexto, vamos explorar brevemente cada um desses princípios SOLID.

Os exemplos de código para cada camada serão em Typescript. Mas podem ser facilmente compreendidos para quem já trabalhou com orientação a objetos.

Single Responsibility Principle (SRP)

O Princípio da Responsabilidade — Uma classe deve ter um, e somente um, motivo para mudar. Esse princípio nos diz que uma classe deve ser especializada em um único assunto e possuir apenas uma responsabilidade dentro do software, ou seja, a classe deve ter uma única tarefa ou ação para executar. Suponha que você tenha uma classe que atualmente é responsável por ler um arquivo e analisar seu conteúdo:

class FileParser {
constructor(private filePath: string) {}
readFile() {
// Lógica para ler o arquivo
}
parseFileContent() {
// Lógica para analisar o conteúdo do arquivo.
}
}

Neste exemplo, a classe FileParser lida com duas responsabilidades: ler o arquivo do sistema de arquivos e analisar seu conteúdo. Isso viola o SRP, pois a classe tem mais de uma razão para mudar. Podemos refatorar esta classe da seguinte forma:

class FileReader {
constructor(private filePath: string) {}
readFile() {}
}
class FileContenteParser {
constructor(private fileContent: string) {}
parse () {}
}

Agora temos duas classes separadas: FileReader para ler o arquivo do sistema de arquivos e FileContentParser para analisar o conteúdo do arquivo. Cada classe tem uma única responsabilidade, tornando o código mais coeso e fácil de manter. Se precisarmos fazer alterações na lógica de leitura do arquivo ou na análise do conteúdo do arquivo, podemos fazê-lo em uma classe sem afetar a outra, seguindo o SRP.

Open-Closed Principle (OCP)

Princípio Aberto-Fechado — Objetos ou entidades devem estar abertos para extensão, mas fechados para modificação, ou seja, quando novos comportamentos e recursos precisam ser adicionados no software, devemos estender e não alterar o código fonte original. Suponha que você tenha uma classe ShapeCalculator que calcula a área de diferentes formas geométricas, como quadrados e círculos:

class ShapeCalculator {
calculateArea(shape: any) {
if (shape.type === "square") {
return shape.sideLength * shape.sidLength;
} else if (shape.type === "circle) {
return Math.PI * shape.radius * shape.radius;
}
}
}
const square = { type: "square", sideLength: 5 };
const circle = { type: "circle", radius: 3 };
const caculator = new ShapeCaculator();
console.log(calculator.calculateArea(square));
console.log(calculator.calculateArea(circle));

Neste exemplo, a classe ShapeCalculator não segue o OCP, pois ela está aberta para modificações. Se quisermos adicionar uma nova forma geométrica, como um triângulo, teremos que modificar a classe ShapeCalculator para incluir a lógica de cálculo do triângulo. Isso viola o princípio OCP, que preconiza que uma classe deve estar fechada para modificações, mas aberta para extensões. Para atender o princípio OCP, podemos refatorar a classe da seguinte forma:

interface Shape {
calculateArea(): number;
}
class Square implements Shape {
constructor(private sideLength: number) {}
calculateArea() {
return this.sideLength * this.sideLength;
}
}
class Circle implements Shape {
constructor(private radius: number) {}
calculateArea() {
return Math.PI * this.radius * this.radius;
}
}

Neste exemplo, seguimos o OCP criando uma interface Shape que define um método calculateArea(). Cada forma geométrica, como Square e Circle, implementa essa interface e fornece sua própria implementação do método calculateArea(). A classe ShapeCalculator agora aceita objetos que implementam a interface Shape, tornando-a fechada para modificações, mas aberta para extensões. Se desejarmos adicionar uma nova forma geométrica, podemos criar uma nova classe que implemente a interface Shape sem modificar o código existente da ShapeCalculator, seguindo assim o princípio OCP.

Liskov Substitution Principle (LSP)

Princípio da Substituição de Liskov — Uma classe derivada deve ser substituível por sua classe base. O princípio da substituição de Liskov foi introduzido por Barbara Liskov em sua conferência “Data abstraction” em 1987. Uma definição simples e de fácil compreensão deste princípio seria: “se S é um subtipo de T, então os objetos do tipo T, em um programa, podem ser substituídos pelos objetos de tipo S sem que seja necessário alterar as propriedades deste programa”, segundo fonte do Wikipedia. Suponha que você esteja desenvolvendo um sistema de gerenciamento de bancos de dados que oferece suporte a diferentes tipos de bancos de dados, como MySQL e PostgreSQL. Você deseja que suas classes de acesso a banco de dados sigam o LSP, permitindo a substituição de uma implementação por outra sem afetar o funcionamento do sistema.

interface Database {
connect(): void;
query(sql: string): void;
disconnect(): void;
}
class MysqlDatabase implements Database {
connect() {}
query(sql: string) {}
disconnect() {}
}
class PostgreSQLDatabase implements Database {
connect() {}
query(sql: string) {}
disconnect() {}
}
const runQuery = (database: Database) => {
database.connect();
database.query("SELECT * FROM ....");
database.disconnect();
}
const mysqlDatabase = new MysqlDatabase();
const postgreSQLDatabase = new PostgreSQLDatabase();
runQuery(mysqlDatabase);
runQuery(postgreSQLDatabase);

O Princípio de Substituição de Liskov é atendido aqui porque podemos substituir uma implementação de banco de dados (MySQL ou PostgreSQL) pela outra sem afetar o funcionamento do sistema. Isso significa que as subclasses podem ser usadas de forma transparente onde a interface Database é implementada, mantendo a funcionalidade esperada e sem causar comportamentos inesperados.

Interface Segregation Principle (ISP)

Princípio da Segregação da Interface — Uma classe não deve ser forçada a implementar interfaces e métodos que não irão utilizar. Ou seja, este princípio nos diz que devemos criar interfaces mais específicas ao invés de uma única interface mais genérica. Suponha que você esteja desenvolvendo um sistema de gerenciamento de usuários com diferentes tipos de autenticação, para isto você criou a seguinte interface:

interface Authentication {
auth(): void;
twoFactorAuth(): void;
}

No entanto, essa interface viola o Princípio da Segregação de Interfaces porque força qualquer classe que a implemente a fornecer implementações para ambos os métodos, mesmo que uma classe específica de autenticação possa não precisar de ambos. Para seguir o ISP, você poderia dividir essa interface em duas interfaces separadas, da seguinte forma:

interface BaseAuthentication {
auth(): void;
}
interface TwoFactorAuthentication {
auth(): void;
}

Agora, você tem duas interfaces distintas, cada uma representando um tipo específico de autenticação. Isso permite que as classes que precisam de autenticação básica implementem BasicAuthentication, enquanto as classes que precisam de autenticação de dois fatores implementem TwoFactorAuthentication.

Dependency Inversion Principle (DIP)

Princípio da Inversão de Dependência — Dependa de abstrações e não de implementações. O DIP estabelece duas principais diretrizes:

  1. Módulos de alto nível não devem depender de módulos de baixo nível.
  2. Ambos, módulos de alto nível e módulos de baixo nível, devem depender de abstrações. O DIP enfatiza a importância de depender de abstrações em vez de implementações concretas. Isso significa que os módulos de alto nível devem depender de interfaces ou classes abstratas em vez de classes concretas de baixo nível. Vamos ver um exemplo simples deste conceito:
class UserRepository {
private db: Database;
constructor() {
this.db = new MysqlDatabase();
}
save(user: User) {
this.db.save(user);
}
}

Para salvar um usuário, a aplicação precisa se conectar com o banco de dados, para isto a aplicação cria uma instância da classe MysqlDatabase em seu construtor. Neste código temos um alto acoplamento de dependências, isso acontece pois a classe UserRepository tem a responsabilidade de criar uma instância da classe MysqlDatabase. Para atender o princípio, podemos refatorar a classe da seguinte forma:

class UserRepository {
constructor() {
private readonly db: Database
} {}
save(user: User) {
this.db.save(user);
}
}

Note que agora a classe UserRepository não sabe mais qual o tipo de banco de dados que está sendo utilizado, ela apenas recebe uma instância de Database e utiliza os métodos que ela oferece.

Importante: A Inversão de Dependência não é a mesma coisa que a Injeção de Dependência. A Inversão de Dependência é um Conceito e a Injeção de Dependência é um Design Pattern.

2. Clean Architecture

Clean Architecture, ou também chamada de Arquitetura Limpa, é um padrão de projeto de software para arquitetura de software que segue os conceitos de código limpo e implementa princípios SOLID. Os padrões de projeto podem ser definidos como boas práticas que ajudam a manter a lógica de negócios unida para minimizar as dependências dentro do sistema. Seguindo os conceitos de arquitetura limpa permite aos arquitetos e engenheiros de software desacoplar componentes para que fiquem isolados o suficiente para serem duráveis e facilmente alterados sem refazer o sistema.

2.1 Principais Camadas

As camadas são muito importantes para garantir uma boa aplicação da arquitetura limpa. Nas próximas seções iremos ver as principais camadas.

Domain

A camada de domínio é uma abstração responsável por encapsular as regras de negócios do sistema e fornecer os casos de usos. Para manter essas classes simples, cada caso de uso deve ter responsabilidade apenas sobre uma única funcionalidade que não deve conter dados mutáveis. Em vez disso, os dados devem ser manipulados na camada de dados. Utilizando a camada de domínio é possível obter alguns benefícios, entre eles, permitir a divisão de responsabilidade, reduzir a duplicação de código e melhorar a testabilidade do aplicativo. Por exemplo, abaixo, temos um exemplo de uma abstração para o caso de uso de criação de um usuário. Esta abstração possui um método add que recebe como parâmetro um objeto com informações do usuário a ser criado.

export interface AddUser {
add: (params: AddUser.Params) => Promise
}
export namespace AddUser {
export type Params = {
name: string;
email: string;
password: string;
active: boolean;
}
}

Data

A camada de dados é responsável por acessar os dados de alguma fonte, seja ela um banco de dados, um arquivo, uma API, etc. Esta camada implementa as regras de negócios definidas pela camada de domínio, ou seja, ela não deve conter nenhuma regra de negócio, apenas implementar as regras definidas pela camada de domínio. Nesta camada também pode haver a dependência de implementações da camada de infraestrutura, ou seja, conexão com o banco de dados, sistemas de cache, APIs externas. Por exemplo na figura abaixo, temos a implementação da abstração AddUser, neste caso voltada para o banco de dados, que recebe por meio de injeção de dependências, uma implementação para fazer a criptografia da senha do usuário, uma implementação para checar se já existe algum usuário cadastrado no banco de dados com o email informado, e por fim a implementação que, em caso de sucesso, irá persistir o novo usuário no banco de dados.

export class DbAddUser implements AddUser {
constructor (
private readonly hasher: Hasher,
private readonly addUserRepository: AdduserRepository,
private checkUserByEmailRepository: CheckUserByEmailRepository,
) {}
async add (params: AddUser.Params): Promise {
const exists = await this.checkUserByEmailRepository.checkByEmail(params.email);
let isValid = false;
if (!exists) {
const hashedPassword = await this.hasher.hash(params.password);
// demais funcionalidades
isValid = await this.addUserRepository.add(userParams)
}
return isValid;
}
}

Infra

A camada de infraestrutura é responsável por se comunicar com serviços externos, como por exemplo, um banco de dados, uma API, ou seja, qualquer serviço externo que a aplicação precise se comunicar, por meio de interfaces e adapters. Neste exemplo, temos a implementação de um Adapter para a dependência externa do bcrypt. Ao utilizar o conceito de Adapters, podemos remover a dependência direta com serviços externos, uma vez que, por exemplo, o serviço fique depreciado, basta alterar a implementação nesta camada que o restante das camadas seguirá inalterado.

export interface Hasher {
hash (plainText: string) => Promise
}
export class BcryptAdapter implements Hasher {
constructor (private readonly salt: number) {}
async hash (value: string): Promise {
return = await bcrypt.hash(value, this.salt)
}
}

Presentation

A camada de apresentação é responsável por expor a aplicação para o usuário, seja ela uma API, um site, um aplicativo, etc. Caso a aplicação seja uma API, é esta camada que ficará responsável por receber os dados da requisição, realizar o processamento e retornar para o usuário. Quando se trata de uma aplicação web/mobile, é esta camada que terá a responsabilidade de executar as ações do usuário, como por exemplo, receber os dados de um formulário, exibido através da camada de Interface de Usuário. Neste exemplo, temos a implementação da Controller para adicionar um usuário, podemos notar que ela implementa uma abstração Controller e recebe por injeção de dependência a implementação para fazer a validação dos dados e o caso de uso para adicionar o usuário, que após o processamento dos dados e inserção do usuário, retorna uma resposta Http para o usuário.

export interface Controller {
handle: (request?: T) => Promise
}
export class AddUserController implements Controller {
constructor (
private readonly validation: Validation,
private readonly addUser: AddUser,
) {}
async handle (request: AddUserController.Request): Promise {
try {
const { name, email, password, active} = request
const error = this.validation.validate(request)
if (error) {
return badRequest(error)
}
const user = await this.addUser.add({
name,
email,
password,
active,
})
if (!user) {
return forbidden(new EmailInUseError())
}
return created(user)
} catch (error) {
return serverError(error)
}
}
}
export namespace AddUserController {
export type Request = {
name: string
email: string
password: string
passwordConfirmation: string
active: boolean
}
}

Validation

A camada de validação é responsável por realizar a validação dos dados informados pelo usuário, seja na camada de UI ou por meio de uma requisição. Por exemplo, nesta camada podemos validar se um campo obrigatório possui valor ou se o CPF informado é válido. Neste exemplo, temos uma implementação da validação de campos obrigatórios.

export interface Validation {
validation: (input: any) => Error | null
}
export class RequiredFieldValidation implements Validation {
constructor(private readonly fieldName: string) {}
validate(input: any): Error | null {
if (!input[this.fieldName]) {
return new MissingParamError(this.fieldName);
}
}
return null;
}

Podemos utilizar o conceito de Factories e Composities, para criar uma instância de validação para injetarmos no nosso Controller, conforme o exemplo abaixo.

export const makeAddUserValidation = (): ValidationComposite => {
const validations: Validations[] = [];
for (const field of ["name", "email", "password", "active"]) {
validations.push(new RequiredFieldValidation(field));
}
validations.push(new BooleanFieldValidation("active"));
validations.push(new EmailValidation("email", new EmailValidationAdapter()));
return nre ValidationComposite(validations);
}

Main

A camada principal é responsável por realizar a composição das demais camadas, por meio de injeção de dependência, sendo também responsável por inicializar a aplicação. Em casos de uma API, esta camada é responsável por inicializar o servidor web e expor as rotas da aplicação. Aqui temos o exemplo de como podemos fazer a composição das camadas e criar uma instância do objeto. Um ponto interessante é que, nesta composição, podemos fazer o uso do conceito de Decorators, e decorar a instância da controller para fazer os logs de erros que podem acontecer durante a execução.

export const makeDbAddUser = (): AddUser => {
const salt = 10;
const bcryptAdapter = new BcryptAdapter(salt);
const userMongoRepository = new UserMongoRepository();
return new DbAddUser(bcryptAdapter, userMongoRepository, userMongoRepository);
}

E por fim podemos expor a rota para a nossa controller, podendo aplicar um Middleware para autenticar e checar a permissão do usuário.

router.post(
"/users",
authorizedRoute({permissionSlug: "USER", action: "create" }),
adaptRoute(makeAddUserController())
)

UI

Esta é a camada em que o usuário interage com a aplicação, através de botões, formulários, exibição de dados em tabelas ou listas, gráficos, infinitas possibilidades, podendo ser implementada em diferentes tecnologias e frameworks.

3. Conclusão

Em resumo, dominar o desenvolvimento de software envolve a aplicação dos princípios de Clean Code, SOLID e Clean Architecture. Essas abordagens promovem código legível, componentes flexíveis e uma estrutura organizada, resultando em sistemas mais robustos e fáceis de manter, essenciais para o sucesso no mundo da programação.

4. Referências

MARTIN, Robert C. Clean Architecture: A Craftsman’s Guide to Software Structure. MARTIN, Robert C. Clean Code: A Handbook of Agile Software Craftsmanship.

5. Glossário

Composites “Composites” (compostos) são estruturas de design que permitem tratar objetos individuais e suas composições de forma uniforme. Isso é frequentemente usado em padrões de design, como o “Composite Pattern”, para criar hierarquias de objetos complexos.

Decorator O “Decorator” (decorador) é um padrão de design que permite adicionar comportamentos adicionais a objetos de forma dinâmica, sem modificar suas classes. Isso é útil para estender funcionalidades de objetos sem alterar seu código existente.

Design Pattern Um “Design Pattern” (padrão de design) é uma solução recorrente para problemas de design de software. É uma abordagem testada e comprovada para resolver desafios comuns de desenvolvimento, fornecendo diretrizes e estruturas para a criação de software de qualidade e fácil manutenção.

Factories As “Factories” (fábricas) são métodos ou classes responsáveis pela criação de objetos. Elas encapsulam a lógica de criação, fornecendo uma maneira flexível de instanciar objetos.

Injeção de Dependência A “Injeção de Dependência” é uma técnica de design de software que separa a criação de objetos e sua utilização. Isso reduz o acoplamento entre componentes, tornando o código mais modular e fácil de testar.

Middleware O “Middleware” é um componente de software que age como intermediário entre diferentes partes de um sistema de software. Geralmente, é usado em sistemas distribuídos ou em pilha de software, como aplicações web, para facilitar a comunicação entre componentes separados. O Middleware pode lidar com tarefas como processamento de solicitações, autenticação, segurança, gerenciamento de transações e muito mais. Ele ajuda a abstrair a complexidade da comunicação entre componentes, tornando o sistema mais flexível e escalável.

William Simionatto Nepomuceno
Engenheiro de Software | Estudante de Ciência da Computação