O que é SOLID?

Compartilhar no facebook
Compartilhar no linkedin
Compartilhar no whatsapp

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:

 

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:

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:

 

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:

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.

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.

 

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:

 

Esta classe abstrata pode ser facilmente utilizada para representar todos os funcionários da empresa que trabalham em 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.

 

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.

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:

 

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.

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:

É 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:

 

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. Como Veículo deve ser uma interface genérica, vamos ver outras implementações:


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.

 

As classes que implementam seriam, então:

 

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.

 

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:

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

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?

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:

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.

 

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.

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

Veja mais
Campinas / SP - Brasil

Estrada Giuseppina Vianelli di Napolli, nº 1.185
Condomínio GlobalTech Campinas
Polo II de Alta Tecnologia
CEP 13086-530 – Campinas – SP
+55 (19) 3755-8600

+55 (19) 3755-8600
contato@venturus.org.br

Bitnami