Princípios SOLID

Marlon Monçores
Bemobi
Published in
11 min readMay 13, 2020
Imagem obtida em: https://br.freepik.com/fotos-vetores-gratis/inverno

Desenvolver um software novo utilizando orientação a objetos é uma tarefa simples para um programador. Contudo, dar manutenção em um software existente, seja para correção de bugs, adição de novas funcionalidades ou alterações de funcionalidades já existentes é uma tarefa muito mais complicada. Não é à toa que o custo de manutenção de um software chega a ser praticamente 70% do custo total.

Custo de cada estágio do ciclo de vida do software

Porque a manutenção é tão cara?

A falta de padrão leva a códigos que são difíceis de se entender. Quanto mais difícil é para entender, mais difícil será a manutenção. Com isso, é comum que a estrutura inicial (que deve ter sido simples) torne-se complexa e o código vire uma “macarronada”. Para piorar, nesse estágio bugs são mais comuns o que implica em correções de última hora, que acabam sendo executadas sem um devido cuidado (muitas vezes durante longas jornadas de trabalho e finais de semana) tornando a estrutura cada vez pior e o software cada vez mais propenso a falhas.

Junta-se a isso o famoso problema da janela quebrada, que pode ser resumido dizendo-se que a negligência é contagiosa. Assim, ao encontrarmos um código que já está ruim, a preocupação em fazer um código bonito sequer existe. Não há mais cuidado em manter o código legível e arrumado. O que leva a uma situação cada vez pior.

Code Smells

Um código mal escrito apresenta alguns sintomas, chamados de code smells, que tornam a vida do desenvolvedor mais difícil. Segue uma lista resumida com alguns smells mais comuns:

Feature envy: Um método utiliza mais dados de outro objeto do que os seus.

Refused Bequest: Campos herdados não utilizados. A herança realmente faz sentido?

Long method: Métodos com mais de 10 linhas são questionáveis.

Primitive Obsession: Usar tipos primitivos ao invés de classes.

Inappropriate Intimacy: Classe utiliza campos e métodos internos de outra.

Shotgun Surgery: Uma única alteração impacta várias classes.

Switch: Switchs ou sequências de IFs são maus indicativos.

Duplication (DRY): Código repetido em diferentes lugares.

Useless comment: Um código bem escrito dispensa comentários. Comentários acabam por poluir o código.

Poor name: Melhor que um comentário é uma variável/método/classe com um bom nome.

God class: Classe com muitos campos e métodos.

Leitura recomendada (em inglês): https://refactoring.guru/refactoring/smells

Efeitos de um código confuso com muitos code smells

  • Rigidez: Cada mudança exige que outras sejam feitas.
  • Fragilidade: Quebras recorrentes e consequências de uma alteração são difíceis de localizar.
  • Imobilidade: Difícil separar componentes para serem reutilizados.
  • Viscosidade: É mais rápido fazer a coisa errada do que a certa.
  • Opacidade: Código difícil de entender.

O código torna-se complexo sem necessidade.

SOLID

Os problemas arquiteturais encontrados durante o desenvolvimento de softwares são sempre os mesmos, independentemente dos requisitos sendo implementados. O SOLID nada mais é do que uma série de 5 princípios que, quando seguidos, tornam o desenvolvimento e manutenção do código mais simples.

Single Responsibility Principle: SRP

Em português, princípio da responsabilidade única. É comum pensar que SRP significa que uma classe deve fazer apenas uma coisa, mas isso é muito abstrato. Uma definição mais correta é:

Uma classe deve ter uma e apenas uma razão para mudar.

Isso significa que uma classe pode ter mais de um método e, efetuar mais de uma operação. Mas todas as operações devem atender a uma mesma razão, ou seja, essa classe nunca será alterada por razões distintas. Vejamos um exemplo simples:

Classe empregado

Essa classe poderá ser alterada se alguma regra de pagamento mudar, ou se a forma como um empregado é salvo ou ainda se a lógica de gerar relatório for alterada. Ou seja, existem ao menos 3 razões para essa classe mudar, por isso essa classe não atente ao SRP.

Sintomas de quebra de SRP

  • Conflitos (merge): Por mudarem por várias razões, as classes acabam sendo modificadas em branches diferentes por razões diferentes, gerando merges.
  • Quebras inesperadas: Ao alterar a classe por uma razão, bugs podem ser inseridos em outros fluxos.
  • Impacto transitivo: Por fazer muitas coisas, essas classes costumam ser utilizadas por diversas outras classes. Alterá-las pode gerar impacto em diversas outras classes.
  • Fan out: Por fazer muitas coisas, a classe também depende de muitas outras classes.

Solução? Divisão de responsabilidades

Seguindo o exemplo anterior, a melhor abordagem seria a segregação da classe Empregado em classes com apenas uma razão para existirem.

Cada classe possui apenas uma razão para existir

Fazendo isso, iremos gerar mais classes, isso tornará a manutenção muito mais simples. Mas teremos um complicador: quando uma parte do sistema precisa que algo referente a esse conjunto de classes seja executado, torna-se mais difícil encontrar a classe exata que está implementando a funcionalidade desejada.

Para resolver esse complicador, pode-se utilizar o padrão de projeto Façade. Mas em resumo, um Façade é uma fachada para uma série de funcionalidades, ou seja, é uma classe que não implementa as funcionalidades, mas conhece quem as implementa e as reúne em um único lugar.

Empregado Fachada não possui a implementação, apenas conhece quem as implementa

É necessário tomarmos cuidado para não haver quebra de SRP na própria fachada. A fachada não poderá ter nenhuma lógica de negócio, pois irá gerar um acoplamento e será impossível testar os componentes isolados. Sendo assim, a fachada possui apenas uma razão para mudar: Quando a integração com outros componentes precisar ser alterada.

Open/Closed Principle: OCP

Em português, princípio aberto/fechado. Esse princípio se resume em:

Classes devem ser abertas para extensão

Classes devem ser fechadas para alteração

Cuidado para não confundir extensão com herança!

Ser extensível tem a ver com depender de interfaces, ou seja, quando uma implementação diferente da interface é passada, o comportamento da classe é alterado (aberto para extensão). Dessa forma, consegue-se alterar o comportamento de uma classe sem a necessidade de alterar o seu código (fechado para alteração).

A principal ideia é separar as “políticas de alto nível” dos detalhes de implementação.

Posso aplicar OCP em tudo?

Não. Em algum momento será necessário fazer a implementação de baixo nível.

  • OCP deverá ser aplicado na menor parte do tempo de forma pró ativa (antes do código estar em produção).
  • Na maior parte dos casos, o OCP surge quando alguma alteração é necessária.

Isso significa que ao desenvolver uma nova funcionalidade, em geral não sabemos se as regras envolvidas serão alteradas no futuro. Então, não é necessário que apliquemos o OCP nessa classe. Mas, tão logo alguma alteração seja solicitada, temos que avaliar se parte do código pode ser “fechada” enquanto que as regras novas tornam-se apenas especializações de uma interface comum.

Exemplo de uma classe não aderente ao OCP.

A classe FolhaDePagamento conhece todos ciclos de pagamento e os tipos de funcionários que existem. Se um novo tipo for adicionado ao sistema, essa classe terá que ser alterada, ou seja, a classe não está fechada para alteração. Além, o código é confuso e não deixa muito claro a real intenção.

Exemplo de uma classe aderente ao OCP.

A nova classe FolhaDePagamento agora está fechada para alteração, mesmo que seja adicionado uma nova lógica para calcular o pagamento de um funcionário, não será necessário alterar o código desta classe.

Esse código é muito mais fácil de ser lido, ele conta uma história, dispensa comentários e não tem detalhes confusos. O objeto empregado escondeu os detalhes de sua implementação atrás de sua interface.

Diagrama de classes da solução proposta

Liskov Substitution Principle: LSP

Em português, princípio da substituição de Liskov (Liskov é o sobrenome da autora — Barbara Liskov). Esse princípio se resume em:

Subtipos devem ser substituíveis de seus tipos base.

Mais do que isso, classes que sejam subtipos (herança), devem defender o contrato da classe base. Uma classe derivada nunca poderá apertar pré-condições ou afrouxar pós condições.

Costumamos achar que podemos utilizar herança quando podemos falar que a classe filha “é um(a)” da classe pai. Mas esse conceito está errado!

O correto é: Herança é quando a classe filha se comporta como a classe pai.

Quando assumimos que é um é suficiente para utilizarmos herança, acabamos criando estruturas erradas. Eis um exemplo:

Um quadrado é um retângulo.

Classe retângulo
Classe quadrado

Tendo essas duas classes, agora vamos imaginar uma classe cliente que receba uma lista de quadrados, faça alterações nas dimensões destes e espera que a área do elemento seja ajustada corretamente.

Classe cliente de Retângulo. O resultado esperado é 12, mas será 16 quando o objeto for um Quadrado.

O teste do exemplo acima falhará, porque embora um quadrado seja um retângulo, um quadrado não se comporta como um retângulo. O quadrado possui uma pré-condição mais restritiva que o retângulo.

Muitas vezes é melhor utilizar composição ao invés de utilizar herança, como no exemplo utilizado no OCP, as classes PagamentoPorSalario, PagamentoPorHora e PagamentoPorComissão não são extensões de Empregado (podemos pensar que que empregado que recebe salário é um tipo de empregado). Contudo, a utilização de composição resolve o problema.

Voltando ao exemplo do Retângulo, a classe QuadradoTeste poderia verificar o tipo do objeto para saber se é um Quadrado ou um Retangulo, e tratá-lo de forma especial. Essa abordagem até poderia corrigir um eventual bug, mas a classe QuadradoTeste passaria a conhecer os subtipos de Quadrado e, caso um novo tipo seja adicionado, ela também teria que passar a conhecer. Ou seja, haveria então quebra de OCP.

Violações de LSP geram violações de OCP

Os sintomas mais comuns de quebra de LSP são:

  • Métodos abstratos que não se aplicam à todas as implementações
  • Classes base (ou Clientes) que conhecem suas sub-classes

Interface Segregation Principle: ISP

Em português, princípio da segregação de Interface. Esse princípio se resume em:

Uma classe não deve depender de coisas que não utiliza

Ao invés de uma interface genérica onde todos os clientes se encaixam, são feitas várias interfaces pequenas e bem específicas.

Da mesma forma como um código não nasce com OCP na primeira versão, o ISP também não é um problema para primeiros releases. Mas a medida que os requisitos são adicionados/alterados, as interfaces previamente criadas podem não fazer mais sentido. Então, o que este princípio preza é:

É melhor dividir a interface preexistente em outras que façam sentido aos seus clientes, do que “engordar” a interface

Adicionar mais métodos a uma interface implica em todos os seus clientes terem métodos que eventualmente não terão código útil, ou retornarão um valor padrão, ou irão jogar uma exception (o que irá quebrar LSP). Vejamos o exemplo:

Interface Aves
Classe Papagaio que implementa Aves

Até esse momento a interface Aves faz sentido porque seu único tipo, Papagaio, precisa de todos os métodos que a interface provê, ou seja Papagaio se comporta como Ave.

Em algum momento do projeto, foi necessário adicionar mais uma classe para representar outra Ave, desta vez um Pinguim. Acontece que pinguins não voam. Então, quando o método altitudeDeVoo() for chamado para um objeto do tipo pinguim, o que deverá ocorrer?

Classe Pinguim que também implementa Aves, contudo pinguins não voam.

O método poderá retornar 0 (como no exemplo), ou até uma exceção especifica informando que pinguins não voam, mas ambas as soluções não estão corretas. O mais correto é segregar a interface em interfaces que façam sentido para seus tipos. A solução ficaria assim:

Interface Aves agora está menor. Atendendo apenas as necessidades de seus tipos.
Surge uma nova interface, AvesQueVoam.
Classe papagaio é alterada para implementar a nova interface.
Classe pinguim não precisa mais implementar o método desnecessário.

Repare que a interface existente, Aves, foi alterada e um de seus métodos acabou sendo migrado para uma nova interface mais específica AvesQueVoam. Por sua vez, foi necessário alterar os tipos para que fiquem aderentes aos novos componentes. Eventualmente, muitos tipos podem depender da interface alterada e precisar de alteração, pode parecer uma quebra de OCP, mas na verdade houve apenas uma mudança na interface implementada, nenhum método ou código foi alterado em Papagaio. Então, na verdade não houve uma quebra de OCP, apenas um refactor.

O LSP também é feito na maior parte do tempo à medida que alterações são feitas no projeto uma vez que não há como antecipar todas as interfaces que um dia podem precisar serem segregadas ao longo da vida de um projeto. Em resumo temos que:

Os clientes definem as interfaces

Dependency Inversion Principle: DIP

Em português, princípio da inversão de dependência. Esse princípio se resume em:

Dependa de abstrações e não de implementações.

Não confunda Inversão de dependência com Injeção de dependência.

Inversão de dependência significa não depender de classes que sejam específicas, mais sim de interfaces (ou classes) mais genéricas possíveis. Por sua vez, a injeção de dependência tem a ver com receber as dependências ao invés de instanciá-las.

Classe está sem Inversão de dependência e sem Injeção de dependência.

Nesse primeiro exemplo, além da classe depender do tipo específico de um banco de dados, a classe ainda sabe como construir esse objeto.

Classe está sem Inversão de dependência e com Injeção de dependência.

Nesse segundo exemplo a classe ainda não utiliza Inversão de dependência, pois ainda depende do tipo de banco de dados específico, mas está utilizando Injeção de dependência, uma vez que o parâmetro é injetado na classe e não mais criado por ela.

Classe está com Inversão de dependência e com Injeção de dependência.

Nesse último exemplo a classe está dependendo de tipo genérico de Driver, ou seja, essa classe não precisará ser alterada caso o banco de dados utilizado seja alterado de PostgresSQL para MySQL por exemplo.

Depender de abstrações, torna o código menos propenso a alterações. Além, de deixar o código reutilizável e facilmente testável. Nesse ultimo exemplo, pode-se passar um Driver mock para a classe e testar a classe independentemente do banco de dados.

Prefira derivar de classes abstratas ou interfaces

Quando sobrescrevemos métodos que já foram implementados em outras classes, é muito provável que haja uma quebra no LSP, pois o método deixou de se comportar como o método da classe pai. Além, qualquer dependência da classe pai existirá na classe filha, o que pode ser desnecessário. Talvez seja melhor criar uma interface, e ambas as classes implementarem essa mesma interface. Dessa forma as dependências de uma classe não será transposta para a outra.

E quando precisamos criar objetos?

A questão é, como criar objetos e não depender dos tipos que foram criados? Por exemplo, retomando nosso exemplo inicial da classe Empregado, como poderia uma classe RH criar novos empregados sem a necessidade de conhecer todas as variações de tipo de empregado?

A solução é a utilização de Abstract Factory. A classe de RH conhecerá apenas a fábrica, que retornará sempre um Empregado.

Solução com Abstract Factory

Pode-se pensar que embora a classe RH não dependa das variações de Empregado, a classe RH ainda possui alguma lógica relacionada ao tipo de empregado que será criado, uma vez que fez a chamada ao método específico da fábrica. Embora essa solução não esteja errada, pode optar por uma solução mais genérica, utilizando uma Dynamic Factory.

Solução utilizando fábrica dinâmica

Os princípios SOLID sozinhos não conseguem garantir que um código estará limpo e fácil de se entender. Mas a utilização dos princípios SOLID nos ajuda a organizar a estrutura de um software de forma mais clara e simples e isso refletirá no código, que também tenderá a ficar mais simples.

Mesmo em softwares onde a estrutura já está ruim, é possível começar a aplicar os princípios SOLID e deixar o código cada vez melhor (ou menos pior). A ideia não é fazer um refactor e corrigir tudo de uma vez (quase sempre é inviável). A principal ideia é:

Deixar o código melhor do que estava quando eu cheguei

Entender os princípios, saber identificar quando houve quebra e como corrigir nos proporciona uma agilidade no desenvolvimento. Conseguimos focar na feature que está sendo desenvolvida e não gastamos tanto tempo pensando em como montar uma estrutura correta. Seguindo os princípios SOLID as chances são grandes da estrutura nascer e se manter correta, simples e fácil de entender.

--

--