Logo do Venturus
O que é SOLID?
  • 30 de junho de 2020
  • Blog

O que é SOLID?

Escrito em parceria com Igor Escodro.

Como desenvolvedores, estamos constantemente tentando implementar as melhores soluções para nossos clientes, considerando os requisitos e restrições que nos são dadas. No entanto, não é raro que clientes alterem os requisitos no meio do projeto, tornando a implementação atual inútil.

Há, também, casos em que temos que trabalhar em um código instável e os requisitos continuam a vir, cada vez mais difíceis de serem encaixados na bagunça atual. Os bugs surgem todos os dias e lutamos para manter o projeto em movimento.

Em casos como estes, muitas vezes nos perguntamos o que poderíamos ter feito melhor para proteger o projeto (e nós mesmos). Embora não haja nenhuma bala de prata nesta situação, melhoras ao design e arquitetura do projeto provavelmente aumentariam a sua qualidade e facilitariam a sua manutenção. Uma maneira de fazer essas melhorias é usando princípios SOLID.

SOLID é um conjunto de princípios e boas práticas para melhorar o design de software e arquitetura, tornando-os mais fáceis de manter, escalar e testar. O nome SOLID é um acrônimo mnemônico dos princípios introduzidos por Robert “Uncle Bob” Martin: Single Responsibility (Responsabilidade Única); Open Closed (Aberto Fechado); Liskov Substitution (Substituição de Liskov); Interface Segregation (Segregação de Interfaces); e Dependence Inversion (Inversão de Dependências). Embora os conceitos tenham sido introduzidos em seu artigo “Design Principles and Design Patterns” em 2000, o acrônimo em si foi sugerido por Michael Feathers algum tempo depois.

Na sua publicação, o Uncle Bob lista quatro “cheiros ruins” que as boas práticas devem ser capazes de evitar:

  • Rigidez: mudanças simples em um único módulo de um projeto resultam em alterações de várias classes de outros módulos, consumindo uma grande quantidade de tempo;
  • Fragilidade: alterações a um único ponto do código podem causar inúmeros efeitos secundários;
  • Imobilidade: o código dentro do projeto não pode ser reutilizado com facilidade, pois possui muitas dependências;
  • Viscosidade: existem dois tipos de viscosidade:
    • Design: fazer alterações que preservam o design de software são consideravelmente mais difíceis do que fazer gambiarras;
    • Ambiente: um ambiente de build muito lento faz com que os desenvolvedores prefiram soluções que requerem menos recursos do sistema (por exemplo: tempo de compilação), mas essas soluções podem quebrar o design.

Ele também menciona que é responsabilidade dos desenvolvedores preparar a arquitetura para lidar com as mudanças de requisitos e que isso deve ser feito com uma boa gestão de dependência.

Neste artigo, apresentamos cada um dos princípios SOLID em mais detalhes, explicando suas definições e dando alguns exemplos de como eles poderiam ser aplicados a cenários do mundo real.

 

Princípio da Responsabilidade Única

De acordo com o princípio da Responsabilidade Única, um componente deve ter uma única razão para mudar. Isto é extremamente semelhante ao conceito de coesão em Orientação a Objetos. Uma classe que tem muitas razões para mudar não é coesa, enquanto uma classe com boa coesão provavelmente terá poucas razões para mudar.

Considere, por exemplo, este código para uma classe responsável pelo processamento de uma ordem de compra:

Código para uma classe responsável pelo processamento de uma ordem de compra

Classe responsável pelo processamento de uma ordem de compra

O PurchaseController está realizando todas as operações necessárias para processar a requisição, incluindo validação e envio de um e-mail de confirmação. Esta classe está claramente fazendo muita coisa extra. Se houvesse uma alteração nas regras de validação, o controlador teria de ser alterado. Se fôssemos adicionar novos passos de cobrança, precisaríamos atualizar o controlador também.

Uma melhor abordagem seria dividir todas as diferentes responsabilidades em diferentes classes, como no exemplo a seguir:

Divisão de todas as diferentes responsabilidades em diferentes classes

Divisão de responsabilidades em diferentes classes

 

Há muitas vantagens nesta abordagem:

  • Melhora a coesão de classes, tornando, assim, mais fácil encontrar uma lógica específica;
  • Torna mais fácil reutilizar o código. Por exemplo, a classe mailSender poderia ser usada em outras partes do projeto para enviar diferentes e-mails;
  • Como é provável que você só precise atualizar algumas classes para adicionar um novo recurso, é mais fácil ter várias pessoas trabalhando no mesmo projeto sem ter problemas de conflito;
  • Como cada pedaço de lógica relacionada está concentrado em classes específicas, é mais fácil depurar quaisquer problemas. Por exemplo, se a validação da ordem não está correta, provavelmente há um problema dentro da classe OrderValidator.

Um exemplo que podemos analisar no framework Android está na classe RecyclerView.Adapter. Quando você herda dele, há alguns métodos que você é obrigado a implementar:

Classe RecyclerView.Adapter

Classe RecyclerView.Adapter

 

O Adaptador tem que saber como inflar um layout, criar um ViewHolder, definir o número de elementos que serão exibidos e, para atualizar os dados sempre que o usuário rola a RecyclerView, possivelmente realizar algum tipo de processamento para determinar os novos dados.

Portanto, podemos concluir que este Adapter poderia ser dividido em mais classes. Isso nos leva a outro ponto. A maior responsabilidade do adaptador é ser uma ponte entre um layout que exibe vários elementos e uma fonte de dados genérica. Ter várias classes executando partes menores do trabalho tornaria o Adapter mais difícil de entender e usar, já que você provavelmente precisaria implementar várias classes para fazê-lo funcionar.

Como vários conceitos no desenvolvimento de software, precisamos ter o cuidado de não estar excessivamente preocupados com o princípio de Responsabilidade Única e tornar o projeto extremamente complexo.

 

Princípio Aberto Fechado

A ideia do princípio Aberto Fechado é que suas classes devem estar abertas para extensão, mas fechadas para modificação. Embora isto possa parecer um pouco confuso, este exemplo deve ajudar:

Princípio Aberto Fechado

Princípio Aberto Fechado

 

A função listResponsibilities devolve uma lista de responsabilidades de um empregado. No entanto, se um novo papel de empregado fosse adicionado, exigiria mudanças na função. Embora esta seja uma solução válida, ela não escala bem. Imagine se houvessem 100 tipos de empregados diferentes. Isso faria com que esta função aumentasse enormemente, tornando-a muito mais difícil de ser mantida. Além disso, e se a lista de responsabilidades de um testador mudasse? Seria necessário passar pelo corpo da função e mudar a linha específica, o que é propenso a erros.

Então, como poderíamos melhorar essa função? O polimorfismo vem ao resgate! Ao definir um contrato, que poderia ser uma classe abstrata ou interface neste caso, podemos criar diferentes classes de funcionários que têm diferentes responsabilidades.

Classes de funcionários que têm diferentes responsabilidades

Classes de funcionários com diferentes responsabilidades

 

Observe como o corpo da função listReponsibilities é muito mais limpo. Agora, se precisamos adicionar um novo empregado, só precisamos adicionar uma nova classe estendendo Employee (ou implementar uma interface, dependendo da solução) e implementar o método getResponsibilities. O problema da mudança das responsabilidades de EmployeeRole também é simplificado, uma vez que poderia ser resolvido com uma alteração do corpo de uma classe específica, em vez de alterar a função de responsabilidade da lista.

O código que lista a lógica dos empregados é, agora, independente das mudanças na estrutura dos empregados. Em geral, podemos dizer que o princípio Aberto Fechado minimiza o impacto que mudanças de requisitos podem causar. Dado isso, podemos dizer que o objetivo final deste princípio é permitir que novas características sejam adicionadas à base de código sem quebrar o código que está atualmente funcionando.

Podemos ver uma aplicação do princípio Aberto Fechado no framework Android. No passado, a classe TextView tinha mais de 20 subclasses diretas e indiretas. Atualmente, no Android 10, ele tem “apenas” 14.

Classe TextView

Classe TextView atualmente possui 14 subclasses

 

Outros problemas à parte, o TextView é um bom exemplo porque o Android lida com qualquer View que contenha texto como um TextView, o que significa que a instância real da View não importa. Em outras palavras, é possível estender o comportamento de uma View contendo texto, mantendo a TextView original intacta.

Embora originalmente destinado a ser aplicado apenas no design de classe, este princípio é também uma boa ferramenta para determinar dependências entre módulos dentro do mesmo projeto. O domain — módulo que contém a lógica de negócio do seu projeto — não deve depender de quaisquer outros módulos. Portanto, ao se comunicar com outros módulos, como o repositório ou as camadas de apresentação da aplicação, o domain deve declarar uma interface de comunicação, para que outro módulo forneça uma implementação.

 

Princípio de Substituição de Liskov

O princípio de substituição de Liskov foi nomeado em homenagem à sua criadora, Barbara Liskov, que afirma que “objetos em um programa devem ser substituíveis por instâncias de seus subtipos sem alterar a corretude desse programa” (Martin, R. C. 2000). Você provavelmente precisará lê-lo algumas vezes para compreendê-lo, mas o princípio em si é fácil de entender.

Vamos começar com uma classe abstrata simples para os funcionários, na qual uma das funções é requisitar férias remuneradas:

Classe abstrata simples

Classe abstrata simples

 

Esta classe abstrata pode ser facilmente utilizada para representar todos os funcionários da empresa que trabalham em tempo integral e estagiários:

Classe abstrata pode ser facilmente utilizada para representar todos os funcionários da empresa que trabalham em tempo integral e estagiários

Classe abstrata representa todos os funcionários de tempo integral e estagiários

 

Agora, todos os funcionários podem solicitar suas férias submetendo o formulário de férias. A função recebe o empregado como parâmetro e faz o pedido ao sistema.

A função recebe o empregado como parâmetro e faz o pedido ao sistema

Função recebe o empregado como parâmetro e faz o pedido ao sistema

 

Mas, um dia, a empresa decide contratar um consultor externo, que não tem férias remuneradas. Por consequência, o sistema não pode permitir que o trabalhador as solicite.

Sistema não pode permitir que o trabalhador as solicite

Sistema nega solicitação

 

O que acontecerá quando a função onVacationFormSubmitted for chamada por um consultor? Você adivinhou bem: o sistema vai falhar, informar que um consultor não tem férias remuneradas.

Embora este exemplo seja muito extremo, ele ilustra muito bem o princípio de substituição de Liskov: mudar a instância de um subtipo não deve fazer o sistema se comportar mal ou parar de funcionar. A instância utilizada deve ser irrelevante para o sistema.

Uma solução simples (e muito feia) é adicionar uma verificação na função para ignorar esta chamada quando um consultor submete o formulário de férias:

Adicionar uma verificação na função para ignorar esta chamada quando um consultor submete o formulário de férias

Verificação na função para ignorar esta chamada quando um consultor submete o formulário de férias

 

Como mencionado na seção anterior, este código quebra o princípio Aberto Fechado. E se amanhã um empregado de terceiros for contratado? Manter este código é caro e perigoso para a aplicação.

Mas como podemos resolver esta questão sem recorrer a verificações de instância? Dividindo as responsabilidades. Em vez de ter a função relacionada com férias em todos os funcionários, podemos criar uma classe abstrata separada apenas para funcionários que possuem férias.

Interface Vacationable

Interface Vacationable

 

Agora, podemos atribuir cada interface para o empregado certo e atualizar a função de Submissão para suportar apenas funcionários com férias:

Atribuir cada interface para o empregado certo e atualizar a função de Submissão para suportar apenas funcionários com férias

Interface para empregado correto

É possível ver o princípio de substituição de Liskov também em Kotlin. Dê uma olhada no seguinte exemplo com lista, que você certamente já escreveu pelo menos uma vez:

Exemplo com lista

Exemplo com lista

 

Como isso é possível? Todas as propriedades esperam uma lista de funcionários, mas aceitam uma lista vazia, um ArrayList e um MutableList sem quaisquer problemas. Esse é o princípio de Liskov em ação: todos os tipos de retorno são subtipos de lista e não importa qual deles está sendo enviado, a funcionalidade é a mesma em todas as implementações.

Este princípio de substituição de Liskov é mais difícil de entender tecnicamente do que quando é aplicado ao código. De fato, como você pode ver no exemplo acima, você provavelmente seguiu-o sem sequer saber. Por outro lado, é fácil quebrar este princípio, mas difícil de detectar essa quebra. Não há atalhos ou “cheiros” explícitos para detectá-la durante o desenvolvimento, mas um bom ponto de partida é revisitar constantemente o código para verificar se o projeto atual continua aderindo aos novos requisitos.

 

Princípio da Segregação das Interfaces

O princípio de segregação de Interface afirma que é melhor ter muitas interfaces específicas para um cliente do que ter uma interface de propósito geral. Para ilustrar este conceito, considere o seguinte exemplo:

A interface Veículo define algumas operações de base previstas para um veículo e a classe Carro implementa os métodos definidos

Interface Veículo define algumas operações de base previstas para um veículo e a classe Carro implementa os métodos definidos

 

A interface Veículo define algumas operações de base previstas para um veículo e a classe Carro implementa os métodos definidos. Como Veículo deve ser uma interface genérica, vamos ver outras implementações:

Outras implementações da interface veículo

Outras implementações da interface veículo

Como podemos ver, a interface do Veículo requer alguns métodos que não são relevantes para as implementações Motocicleta e Bicicleta. Como a interface exige que as classes de implementação tenham os métodos declarados, é necessário ter esses métodos implementados com um corpo vazio. Se o número de métodos vazios for baixo, isso pode ser aceitável, mas ter muitos deles é algo para ficar atento.

Para seguir o princípio, a interface do veículo deve ser dividida em interfaces menores, que sejam mais relevantes para as classes que as implementam.

Interface do veículo deve ser dividida em interfaces menores

Interface veículo dividida em interfaces menores

 

As classes que implementam seriam, então:

Classes implementadas

Classes implementadas

 

Neste caso, não é necessário declarar métodos sem implementações.

Há também um outro lado deste princípio. As classes de clientes da interface não precisam saber sobre um grande número de funções não relacionadas. Por exemplo, se houver uma implementação de um posto de gasolina que irá encher os veículos, ele não precisa saber sobre a existência da função openTrunk (abrir porta-malas).

Um exemplo na estrutura Android do princípio de segregação de Interface é a interface TextWatcher.

Interface TextWatcher

Interface TextWatcher

 

Embora todas as funções da interface estejam relacionadas, não é incomum que uma aplicação precise acessar apenas um dos métodos de callback. No entanto, implementar a interface requer que todas as funções sejam declaradas, então, você, muitas vezes, acaba com duas funções vazias poluindo sua classe.

 

Princípio da Inversão da Dependência

O princípio de inversão de dependência pode ser resumido pela frase “dependa de abstrações. Não dependa de concreções”. Este princípio é uma estratégia baseada em dois pilares:

  1. Os módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações;
  2. Abstrações não devem depender de detalhes. Os detalhes devem depender de abstrações.

Então, vamos discutir esses pontos apresentando um exemplo: imagine que você tem um módulo que contém os ativos mais valiosos de seu sistema, a lógica de negócios. Uma vez que a lógica de negócio é implementada, é improvável que seja alterada até que os requisitos mudem. O módulo de lógica de negócios não tem todas as informações necessárias para funcionar sozinho, então, ele precisa solicitá-las para outros módulos, um banco de dados local, por exemplo. Esta interação pode ser representada abaixo:

Módulo de regras de negócio e módulo de base de dados local

Módulo de regras de negócio e módulo de base de dados local

 

A representação de código para esta relação pode ser vista abaixo:

Representação de código a relação entre módulos de regra de negócio e base de dados local

Código da relação entre módulos de regra de negócio e base de dados local

 

O módulo de Regras de Negócio acessa a base de dados local diretamente para obter todas as informações necessárias para executar. Podemos dizer que o módulo de Regras de Negócio depende do módulo de banco de dados local. Esta relação funciona bem até que um inconveniente requisito não funcional, como mudar o banco de dados local para um remoto, aparece. É simples. O que pode dar errado?

Problemas no módulo de regras de negócio

Problema com a política high level no módulo de regras de negócio

 

Basicamente, temos dois problemas aqui: uma vez que o módulo de regras de negócio depende do banco de dados local, assim que removemos o módulo de banco de dados local, o código não compila mais. Outro problema é que precisaremos refatorar a Regra de Negócio, o módulo mais importante do sistema, tornando-o volátil.

Podemos aplicar o princípio de inversão de dependência a este cenário, tornando o módulo de Regras de Negócio dependente de uma abstração em vez de uma política de baixo nível. O novo fluxo é representado abaixo:

Módulo de regra de negócio e Base de dados local com abstração

Módulo de regra de negócio e Base de dados local com abstração

 

Nesta imagem, a linha sólida representa o fluxo de dependência e a linha pontilhada representa o fluxo de controle. O fluxo de dependência, como o nome sugere, indica a dependência direta entre dois pontos do código. No código, a política de baixo nível implementa ou estende a abstração.

O fluxo de controle, por outro lado, representa o fluxo de execução. Representa a política de alto nível acessando a política de baixo nível em termos de a abstração. Para a política de alto nível, não importa se a política de baixo nível é um banco de dados local, nuvem ou em memória. Apenas espera-se que alguma política corresponda a esse contrato de abstração.

Abstração da relação entre regras de negócio e base de dados local

Abstração da relação entre regras de negócio e base de dados local

 

Com esta nova estrutura, como se comportará o módulo de Regras de Negócio ao remover o módulo de Base de dados local? Ele simplesmente não será impactado, já que suas dependências estão dentro do Módulo de Regras de Negócio apenas.

Relação entre política alto nível e abstração no módulo de regras de negócio

Relação entre política alto nível e abstração no módulo de regras de negócio

 

A aplicação do princípio da inversão de dependências confere ao sistema as seguintes vantagens:

  • Flexibilidade: classes concretas mudam muito (bibliotecas, framework, requisitos não-funcionais), classes abstratas, nem tanto. Confiar na abstração permite a mudança de implementações e o desenvolvimento de novas funcionalidades;
  • Confiabilidade: as políticas de alto nível contêm partes importantes do sistema e não devem ser atualizadas com base em alterações de políticas de baixo nível;

Este princípio nos permite proteger a parte mais importante do código, tornando-o independente de outros componentes. Ao criar uma política de alto nível em seu projeto, sempre se pergunte se ela contém uma política de baixo nível. Se sim, esta política de baixo nível é um bom candidato para ter sua dependência invertida.

 

Considerações Finais

Os princípios representados pela sigla SOLID nos guiam para criar uma melhor arquitetura e nos preparar para futuras mudanças. Embora os princípios sejam muito bem estruturados e fáceis de seguir, eles não garantem uma “arquitetura perfeita” (se tal coisa existe). O conhecimento da arquitetura vem com experiência e tempo, mas conhecer esses princípios vai ajudá-lo a resolver problemas que você provavelmente já enfrentou, mas não tinha as ferramentas para superar.

 

Referência