Introdução
Desenvolver Software pode parecer uma tarefa simples, principalmente hoje com os “IA Agents” e ferramentas que nos auxiliam a desenvolver de forma rápida uma funcionalidade. Na realidade, à medida que o Software vai crescendo fica bem mais complexo mantê-ló. A prova disso são as criações de padrões de projetos, arquiteturas, frameworks e bibliotecas que tem como objetivo criar pedaços “SÓLIDOS” (que belo trocadilho, eim?! 🤣) de código que possam ser integrados aos nossos softwares.
Um desses padrões de desenvolvimento é o S.O.L.I.D., acrônimo que define formas de desenvolver que nos ajudam a criar softwares expansíveis, manuteníveis e escaláveis. Cada acrônimo se conecta a um e outro de forma bem interessante, e diferente de outros artigos que você vai ler ou já leu, esse mostra como cada um desses se integram e inclusive podem se complementar de forma a você nem perceber que já está aplicando um dos acrônimos ao já estar utilizando outro. Vamos dar uma olhada em cada uma das letras do acrônimo abaixo:
S - Single Responsibility Principle
O Princípio da Responsabilidade Única, como é conhecido em português, diz respeito a criar “módulos” no seu código que sejam capazes de serem importados em qualquer lugar. Quando desenvolvemos software, pode parecer simples definir pastas, módulos ou pacotes que definem que tipos de classes, código ou funções vão estar ali, só que não é sobre isso. Quem vai desenvolver um software junto com você, tem que ser capaz de ver os pacotes e entender como reutilizar os módulos já existentes e criar novos pacotes que seguem essa mesma prática.
Os motivos para um software mudar são determinados pelas necessidades das pessoas. Logo, observe bem esse motivos para mudar o software. Logo te faço uma pergunta, onde haverão essas mudanças, um pacote inteiro mudará? Caso sim, seu software não está seguindo esse princípio.
Com a aplicação desse princípio, ajudamos a evitar efeitos colaterais em nossas entregas pois mudamos apenas uma determinada parte do nosso software sem mudar o restante dele. Para ficar mais claro como podemos aplicar esse princípio vamos a um exemplo:
Supondo que você agora está desenvolvendo a parte de um software que está tratando da gestão da empresa, isto é, a parte financeira, operações e TI agora devem estar sob esse domínio. Ao pensar no modelo da classe Funcionario, responsável por tratar tudo de funcionário, pode ser tentador criar vários métodos dentro dessa classe, tal qual: calcularHoras, calcularSalario, salvarDados. Só que ao fazer isso você está misturando “domínios” diferentes em uma classe só, e quando for alterar ou essa classe ou algo desse domínio, essa classe tende a aumentar ou então a ser alterada de modo a surtir alterações em outros lugares.
Nesse exemplo então o ideal seria fazer o seguinte:
classe Financeiro {
funcao calcularSalario(Funcionario $funcionario)
}
classe Operacional {
funcao calcularHoras(Funcionario $funcionario)
}
classe AcessoAoBanco {
funcao salvarDados(Funcionario $funcionario)
}Quando fazemos essa separação, fechando cada módulo e deixando claro o que ele faz, evitamos que mudanças em cada pequeno pedaço do nosso código acabe fazendo com que todo o nosso código seja alterado. Além disso, fica claro saber como criar novos módulos e reutilizar os já existentes.
O - Open/Closed Principle
O “S”, princípio da responsabilidade única, precisa ser bem entendido e estar bem aplicado antes de seguirmos no “O”, princípio do aberto/fechado como é conhecido em português. Sem uma divisão clara do nosso código, não temos como continuar mantendo nosso software pois não está claro que já se tem e para onde estamos expandindo impedindo assim a criação dos módulos que aplicam o “O”.
Assim, criar pacotes bem feitos pode mudar a dinâmica de uma empresa, pois com esses pacotes que podem ser reutilizados a empresa evita criar coisas novas, ou seja, gastar tempo (que é dinheiro) fazendo um módulo que muito se assemelha a outro. Além disso, um módulo bem feito geralmente muda muito pouco, ou quando muda, tende a mudar muito pouco outros módulos que dependem deste.
Inclusive, mudar um módulo que acaba alterando vários outros pontos do seu software tem um nome, que descrevo abaixo:
Shotgun Surgery ou Cirurgia de Espingarda
-
É exatamente o inverso de uma programação modular sadia. Geralmente os programadores com mais experiências já se depararam com esse tipo de situação onde uma pequena alteração acaba mudando VÁRIOS outros pontos do seu software.
“Um módulo deve estar aberto para extensão mas fechado para modificação”
Um pacote ou módulo pode ser considerado fechado quando ele está totalmente disponível para ser reutilizado por outro módulo, de modo que ele está tão bem definido que pode ser armazenado em uma biblioteca e utilizado sem que em atualizações posteriores cause mudanças nos pacotes ou módulos que o utilizam.
Vamos dar uma olhada no código de exemplo abaixo:
classe Salarios {
constante SALARIO_BASE = 1500.00
funcao calcularSalarioGerente() {
retorna SALARIO_BASE * 3
}
funcao calcularSalarioOperario() {
retorna SALARIO_BASE * 1.5
}
}
classe Financeiro {
variavel Salarios $salarios
funcao pagarFuncionario(texto $tipoFuncionario) {
variavel salario
se ($tipoFuncionario == "GERENTE") {
salario = $salarios.calcularSalarioGerente()
}
se ($tipoFuncionario == "OPERARIO") {
salario =$salarios.calcularSalarioOperario()
}
// rotina para pagar salario
}
}No exemplo acima, apesar de um pouco bem divididas as classes, cada qual com sua responsabilidade, temos alguns problemas. Na classe Financeiro, observe o problema do método pagarFuncionario pelo cargo. Se novos cargos forem adicionados, vamos ter que adicionar aqui nesse método esse novo cálculo. Também temos a classe Salarios, que tem o calcularSalarioOperario, calcularSalarioGerente e outros mais. Toda vez que um novo tipo de funcionário surgir, vamos precisar alterar essas duas classes.
Assim, fere o princípio de aberto fechado pois a classe não está fechada para modificação, isto é, para adicionar um novo tipo de funcionário é necessário alterar essa parte. E não está aberta para extensão, pois para que ela calcule o salário é necessário passar saber qual o tipo de funcionário para calcular o valor. E não está fechado para modificação pois toda vez que um novo tipo de funcionário aparecer, temos que adicionar um novo “se” no método pagarFuncionario da classe Financeiro.
Vamos então fazer o ajuste para que com isso tenhamos um código aberto para extensão e fechado para modificação, vejamos:
interface IFuncionario {
funcao calcularSalario(): NumeroReal
}
classe Operario implementa IFuncionario {
funcao calcularSalario() {
retorna SALARIO_BASE * 1.5
}
}
classe Gerente implementa IFuncionario {
funcao calcularSalario() {
retorna SALARIO_BASE * 3
}
}
classe Financeiro {
funcao pagarFuncionario(Funcionario $funcionario) {
variavel salario = funcionario.calcularSalario()
// rotina para pagar salario
}
}Agora, toda vez que quisermos adicionar um novo tipo de funcionário, basta que criemos uma nova classe desse novo tipo e implementemos a interface IFuncionario. Fazendo isso, não precisamos mais modificar a classe Financeiro e o método pagarFuncionario, fazendo esse código ser fechado para modificação e aberto para extensão.
L - Liskov Substitution Principle
À medida que vamos avançando nos nossos estudos, observamos que um princípio ajuda o outro a se complementar. Por exemplo, a aplicação do “Single Principle Resource” ajudou “Open/Closed Principle”. Dessa mesma forma, o “Liskov Substitute Principle" se complementa pelos outros que já falamos.
O princípio de substituição de Liskov como é conhecido em português, foi criado por Barbara Liskov em sua obra a respeito da abstração de dados e teoria dos tipos em 1988. Neste princípio temos a seguinte premissa: “Classes derivadas devem sempre ser substituíveis por suas classes base”.
O que isso quer dizer? Quer dizer que quando vamos implementar o nosso código, devemos ter sempre uma classe base (ou interface) com métodos bem definidos, que dada uma entrada retorne uma saída clara e que seja de fato respeitada por aqueles que agora implementam esses métodos.
Quando você segue essa regra, esse “contrato”, seu programa e aqueles que o mantém vão saber que devem confiar nas classes que estão implementando aquela interface ou classe base. Quando isso não ocorre, muitos efeitos colaterais e/ou grandes alterações são passíveis de serem criados(as) ao realizarmos a manutenção do nosso código. Logo, ter uma base forte é extremamente importante, principalmente a medida que um software cresce.
Vejamos o diagrama abaixo:
Como nossa interface IFuncionario é forte, define um bom contrato, toda e qualquer classe que a implemente vai ter bem definido o método calcularSalario que deve retornar um salário válido. Logo, não teremos nenhum efeito colateral ou problema ao adicionar novas funcionalidades quando adotamos esse princípio. Além disso, é importante evitar o lançamento de erros não esperados nesse métodos para que assim não introduzamos efeitos colaterais na implementação dos métodos das interfaces tais quais IFuncionario.
Em Java chamamos de
UncheckedExceptions, adicionar isso no código vai te causar problemas a não ser que esteja utilizando frameworks tais quais o Spring Boot em que a premissa de tratamento de erro é exatamente lançar exceções não checadas.
E de bônus, observe que, ao aplicar esse princípio, já ganhamos “O”, “open/closed principle”, pois com classes bases e/ou interfaces confiáveis o nosso código acabam deixando sempre o nosso software aberto para extensão e fechado para modificação.
I - Interface Segregation Principle
O princípio de segregação de interfaces como é conhecido o “I” do S.O.L.I.D. em português, diz que idealmente é interessante que sejam criadas pequenas interfaces que definem unicamente uma implementação específica.
Mas porquê devemos fazer isso? Quando estamos desenvolvendo um software pode parecer interessante adicionar a uma interface vários métodos que sejam obrigatórios para as classes que implementam essa interface, mas na verdade não é, vou explicar melhor com o exemplo que temos visto neste artigo.
Imagine que você criou a interface IFuncionario, e passou a colocar todo “contrato” dentro dessa interface de modo a todos que a implementam crie seus métodos. As classes que implementam esse IFuncionario são Gerente, Vendedor e Operario. Temos na interface IFuncionario com os métodos calcularSalario, que diz respeito a todos os tipos de funcionários, mas temos o gerenciarProdutos que é do Gerente, venderProdutos que é do vendedor e operarEstoque que é do Operario. Veja que funcionários que não deveriam ter certos comportamento estão tendo. As vezes o programador inclusive adiciona um lançamento de exceção nesses métodos que não dizem respeito as classes implementadas, piorando ainda mais a situação pois isso contribui para adição de bugs e feitos colaterais dados que é esperado um retorno e não uma exceção nesses métodos como falamos no tópico anterior.
Sendo assim, aplicando o princípio da segregação da interface, o problema acima seria resolvido da seguinte forma:
interface IFuncionario {
funcao calcularSalario(): NumeroReal
}
interface IGerente {
funcao gerenciarProdutos()
}
interface IOperario {
funcao operarEstoque()
}
inteface IVendedor {
funcao venderProdutos()
}
classe Operario implementa IFuncionario, IOperario {
funcao calcularSalario() {
retorna SALARIO_BASE * 1.5
}
funcao operarEstoque() {
// detalhes de implementação
}
}
classe Gerente implementa IFuncionario, IGerente {
funcao calcularSalario() {
retorna SALARIO_BASE * 3
}
funcao gerenciarProdutos() {
// detalhes de implementação
}
}
classe Gerente implementa IFuncionario, IVendedor {
funcao calcularSalario() {
retorna SALARIO_BASE * 3
}
funcao venderProdutos() {
// detalhes de implementação
}
}Agora toda vez que uma nova função existir temos a opção de adicionar comportamentos já existentes das novas funções ou criar um novo comportamento, tudo isso sem quebrar nenhuma implementação ou se quer adicionando métodos que não deveriam existir a depender da função. Além disso, podemos inclusive criar uma interface que é a união de duas sem problemas.
D - Dependency Inversion Principle
Em português, princípio da inversão de dependência. Aqui já começamos a observar como os princípios se complementam pois se o “O” e o “L” estiverem sendo cumpridos corretamente esse também estará.
Antes de entramos adentro nesse princípio, precisamos entender alguns conceitos para podermos desenvolver a ideia de como aplicar o “D”, vejamos:
- Modulo de alto nível são geralmente as partes mais fáceis de entender de um sistema. Aqui o desenvolvedor não tem dificuldades de entendimento.
- Modulo de baixo nível são partes mais complexas de entender. Rotinas, cálculos, transformações de dados e comportamentos específicos estão presentes aqui.
- Detalhes são artefatos que fazem parte do sistema e são necessários para que ele funcione, não deveriam estar acoplado na arquitetura. Exemplos: bancos de dados, filas, sistemas de arquivo…
Um dos maiores objetivos do S.O.L.I.D. é diminuir o acoplamento, um grande problema no desenvolvimento de software. Softwares acoplados tendem a ter difícil manutenção, o que aumenta muito o seu custo. Vamos observar abaixo um exemplo de acoplamento de código:
classe NotaFiscal {
funcao gerarNotaFiscal(inteiro $idPedido) {
$precoPedido = calcular($idPedido)
// rotina de gerar nota fiscal
}
}
classe Calculo {
funcao calcular(inteiro $idPedido) {
$produtos = obterProdutos($idPedido)
// rotina que calcula valor dos produtos
return 789.43
}
}
classe Produtos {
funcao obterProdutos(inteiro $idPedido) {
// rotina que busca no banco pelo ID e retorna lista de pedidos
retorna [pedidos...]
}
}No exemplo mostrado acima, as dependências seguem a seguinte sequência: módulo de alto nível (notaFiscal.gerarNotaFiscal) → módulo de baixo nível (calculo.calcular) → detalhe (produtos.obterProdutos).
Mesmo usando o paradigma da orientação a objetos, ainda assim vamos ter acoplamento de código, pois do módulo de alto nível até o detalhe temos dependência, e uma alteração na entrada ou saída em qualquer um muda tudo em todo código.
A medida em que vamos tendo mais experiência, observamos que é impossível escrever um software de modo que ele seja livre de acoplamento. Por isso, um desenvolvedor experiente tem que ter a capacidade de lidar com uma coisa: escolher bons acoplamentos frente aos acoplamentos ruins! Fazer e entender isso vai ser a chave para que o software cresça de forma saudável ou se vai ser de difícil manutenção. Um bom exemplo de escolhas de bons acoplamentos é o uso de uma framework ou biblioteca! Se fossemos realmente escolher deixar tudo desacoplado (além de ser impossível, eu diria) seria extremamente trabalhoso fazer o todo o código de uma boa framework ou biblioteca, seria até uma decisão ruim pois estaríamos refazendo a roda!
Sendo assim observemos o exemplo abaixo para aplicar o “D”, o princípio da inversão de dependência e ver o ganho que teremos:
classe NotaFiscal {
variavel ICalculo calculo
funcao gerarNotaFiscal(inteiro $idPedido) {
$precoPedido = calculo.calcular($idPedido)
// rotina de gerar nota fiscal
}
}
interface ICalculo {
funcao calcular(inteiro $idPedido)
}
classe Calculo implementa ICalculo {
variavel IProdutos produtos
sobrescreve funcao calcular(inteiro $idPedido) {
$produtos = produtos.obterProdutos($idPedido)
// rotina que calcula valor dos produtos
return 789.43
}
}
interface IProdutos {
funcao obterProdutos(inteiro $idPedido)
}
classe Produtos implementa IProdutos {
sobrescreve funcao obterProdutos(inteiro $idPedido) {
// rotina que busca no banco pelo ID e retorna lista de pedidos
retorna [pedidos...]
}
}Com esse ajuste, agora todas as classes concretas dependem da assinatura definida (contrato) pelas interfaces, que foi definida para suprir a necessidade dos módulos superiores, ou seja, módulo de nível alto e baixo. Qualquer ajuste interno, na implementação, não surtirá efeito nas classes de nível mais alto pois devem ser capazes de seguir o que foi definido no contrato, assim um ajuste interno na implementação da classe Produtos ou Calculo não deve mudar nada para a classe NotaFiscal, fazendo assim que a dependência seja invertida.
E ai que vem a grande jogada, com o acoplamento a coisas abstratas em nosso código, o “L” alcança sua flexibilidade, e impulsionando o “D” e fazendo com que o “O” meio que esteja aplicado por padrão.
Lembre de ser maleável, não queira apenas utilizar as coisas sempre “by the book” pois podem haver casos em que seja realmente necessário depender de uma classe concreta.
Conclusão
Nesse artigo completo a respeito do S.O.L.I.D passamos letra em letra do acrônimo entendendo como os princípios funcionam, quais os problemas estamos resolvendo, quais ganhos teremos e principalmente como eles se conectam. Aprender fundamentos tais quais esses podem ser a diferença no dia a dia de um desenvolvedor experiente capaz de criar soluções que tendem a ter sucesso a longo prazo. Também entendemos que todo tem que ser utilizado com sabedoria, as vezes sair aplicando apenas as coisas que aprendemos sem pensar direito no problema que estamos resolvendo pode ser um tipo no pé.
Por último, vimos também como cada uma das letras do acrônimo se conectam, ao aplicar o “S” fica mais fácil de aplicar o “O”. Ao aplicar o “S” e o “O” pode ser que já tenhamos o “L” pois criamos bases fortes para o nosso software. Por fim, eu diria que o “I” é a cereja do bolo, é simples de aplicar e ajuda a manter o software bem separado e de fácil expansão e o “D” trás a nós o entendimento a respeito de como escolher onde nosso software se acoplará.
Referências
Já estudo S.O.L.I.D a muito tempo, desde artigos a livros. Um dos principais livros que li a respeito foi esse:
Orientação a Objetos e SOLID para Ninjas: Projetando classes flexíveis
O outro livro que li a respeito foi esse do Robert C. Marting:
Arquitetura Limpa, o guia do artesão para estrutura e design de software.
E por último, aprendi muito com os artigos do Medium listados abaixo: