Princípios SOLID: criando e mantendo softwares simples e eficientes

Itaú Tech
ItauTech
Published in
10 min readFeb 14, 2023

Por Luiz Felipe Russi Lobato Guerrero, Team Member na Comunidade de Cash Management no Itaú

No lado esquerdo da imagem, há a frase “Princípios SOLID: aplicando cinco princípios para o desenvolvimento de softwares mais simples e eficientes”, sobre um fundo laranja. No lado direito, há a foto de uma mulher parda, de cabelo longo e enrolado, encarando um computador. Ela veste uma camisa de lã na cor bege e usa óculos.

O que define a qualidade de um código? Se você atua com desenvolvimento de softwares, com certeza você já deve ter tido algum tipo de atraso no processo por ter se deparado com um código de baixa qualidade.

De acordo com Sultan Mahamud, CEO da companhia de desenvolvimento de softwares 6amTech, “um código ruim é quando um desenvolvedor quer fazer as coisas de forma rápida, sem pensar muito nas alterações futuras e sem se importar com a possibilidade de que outro profissional precise utilizar o mesmo código”.

É justamente para evitar esse tipo de situação que foram criadas várias boas práticas e arquiteturas de software ao longo dos anos (uma delas é a metodologia de 12 fatores, com foco no desenvolvimento na nuvem, por exemplo). No artigo de hoje, vamos falar sobre a SOLID: uma disciplina composta por cinco princípios ou guidelines que nos ajudam a criar softwares mais simples de se construir e manter.

A seguir, vamos entrar no detalhe de cada um dos princípios e ver alguns exemplos escritos em Kotlin.

Para começar, o que é o SOLID?

SOLID é um acrônimo que consolida 5 pilares para o desenvolvimento de softwares que podem nos ajudar a construir um código mais flexível, sustentável, compreensível e aberto a alterações. São eles:

· S — Single Responsibility Principle (ou princípio de única responsabilidade);
· O — Open/Closed Principle (ou princípio de aberto/fechado);
· L — Liskov Substitution Principle (ou princípio de substituição de Liskov);
· I — Interface Segregation Principle (ou princípio segregação de interface);
· D — Dependency Inversion Principle (ou princípio de inversão de dependência).

Agora, vamos nos aprofundar em cada um desses princípios:

S — Single Responsibility Principle (ou princípio de única responsabilidade)

De acordo com a metodologia, cada classe deve ter apenas uma responsabilidade. Essa responsabilidade é definida como uma ‘razão para mudar’ — e cada classe ou módulo deve ter uma (e apenas uma) razão para ser alterada.

Para facilitar o entendimento, vamos seguir com um exemplo do que não devemos fazer:

class Pedido {

fun enviarNotificacao() {
// Envia uma notificação do pedido
}

fun salvar() {
// Salva o pedido no banco de dados
}

fun criaOrdemDePagamento() {
// Cria uma ordem de pagamento para o pedido
}
}

Conseguiu encontrar o erro? Nessa classe de pedido, temos mais de uma responsabilidade dentro dela: salvar uma informação no banco de dados, enviar uma notificação e criar uma ordem de pagamento. Consequentemente, temos mais de uma razão para essa classe ser alterada.

Segundo esse princípio, o ideal seria dividir essa classe para cada uma de suas responsabilidades, conforme o exemplo abaixo.

A classe de pedido, contendo apenas o que é necessário para montar um pedido:

class Pedido(val numeroPedido: Int, val valorPedido: Int) {

fun criaOrdemDePagamento() {

// Cria uma ordem de pagamento para o pedido

}

}

A classe de notificações, que recebe nosso objeto de pedido e faz os envios necessários:

class Notificacao(val pedido: Pedido) {
fun enviarNotificacao() {
// Envia uma notificação do pedido
}
}

E, por último, a classe de repositório, que fica responsável por acessar e salvar no banco de dados da classe pedido:

class PedidoRepository(val pedido: Pedido) {
fun salvar() {
// Salva o pedido no banco de dados
}
}

Dessa maneira, temos cada classe com um único contexto/responsabilidade, respeitando o princípio.

O — Open/Close Principle (ou princípio de aberto/fechado)

Essa terminologia indica que entidades de software devem ser abertas para extensão, mas fechadas para modificação.

Na década de 90, quando o princípio foi popularmente disseminado, o aberto/fechado fazia referência direta à implementação de interfaces e à herança de classes abstratas — fazendo com que a classe base não sofresse alterações e ajustes que pudessem quebrar sua compatibilidade.

Hoje em dia, em linguagens como Kotlin, por exemplo, é possível criar extensões de classe para adicionar novas funcionalidades a uma classe fechada, sem alterar o funcionamento base da classe e sem violar o princípio.

Vamos a mais um exemplo de implementação do princípio. Primeiro, vamos mostrar um uso incorreto, e na sequência, o código ajustado:

Aqui há um enum class que diz quais tipos de notificações um app pode enviar:

enum class Notificacao {
EMAIL,
SMS
}

E aqui há uma Service, responsável por fazer os disparos dessas notificações:

class NotificacaoService {
fun enviarNotificacao(notificacao: Notificacao) {
when (notificacao) {
Notificacao.EMAIL -> {
// Envia email
}
Notificacao.SMS -> {
// Envia SMS
}
}
}
}

Imagine que o aplicativo vai começar a enviar novas notificações via push notification. Para fazer esse tipo de ajuste, é necessário ajustar tanto a classe enum de notificação quanto a NotificaçãoService, o que pode causar problemas futuros de compatibilidade.

Para adequar o código ao princípio, é preciso utilizar interfaces para implementar o envio de notificações. A classe de Notificação ficaria assim:

interface Notificacao {
fun enviaNotificacao() {
// Fun que será implementada nas classes
}
}

Dessa forma, as classes podem implementar essa interface e fazer os tratamentos necessários, conforme abaixo:

class EmailNotification : Notificacao {
override fun enviaNotificacao() {
// Faz o envio do email
}
}
class SmsNotification : Notificacao {
override fun enviaNotificacao() {
// Faz o envio do SMS
}
}

Com isso, é criado um código mais escalável que nos permite acrescentar novos meios de notificação, sem interferir com a classe base e nem com nenhuma outra que a use.

L — Liskov Substitution Principle (ou princípio de substituição de Liskov)

Outro conceito delineado pela metodologia é de que objetos em um programa devem ser substituíveis por instâncias de seus subtipos, sem alterar a funcionalidade do programa.

Este talvez seja o princípio mais complexo de se entender à primeira vista, e está diretamente ligado a como utilizamos as classes e suas heranças. Conseguimos simplificar um pouco este conceito se reescrevermos dessa maneira: uma classe base pode ser substituída pela sua classe derivada.

Vamos dar uma olhada em como seria uma implementação que não segue esses padrões. Aqui temos uma classe base chamada Arquivo e duas outras classes que a herdam:

open class Arquivo {
// Parametros do arquivo
}
class ArquivoPDF : Arquivo() {
fun gerarPDF() {
// Gera o PDF
}
}
class ArquivoWord : Arquivo() {
fun gerarWord() {
// Gera o Word
}
}

Observe que as duas classes ArquivoWord e ArquivoPdf herdam de Arquivo, provavelmente para reaproveitar algum campo ou comportamento. No entanto, cada uma das derivadas tem seu próprio método de geração.

Agora, imagine que temos uma classe que recebe um objeto do tipo Arquivo para fazer a sua impressão:

class ImprimirService {
fun imprimir(arquivo: Arquivo) {
when (arquivo) {
is ArquivoPDF -> {
arquivo.gerarPDF()
}
is ArquivoWord -> {
arquivo.gerarWord()
}
}
}
}

Perceba que assim, mesmo quando utilizamos um objeto do mesmo tipo, precisamos validar qual é o seu subtipo para conseguirmos realizar uma tarefa.

Uma das formas de se adequar as classes ao princípio é fazer com que a nossa classe Arquivo tenha a função base de gerar o arquivo, enquanto cada uma das classes filhas (sub-classes) faz sua implementação. No exemplo, ficaria assim:

abstract class Arquivo {
abstract fun gerarArquivo()
}
class ArquivoPDF : Arquivo() {
override fun gerarArquivo() {
// Gera o PDF
}
}
class ArquivoWord : Arquivo() {
override fun gerarArquivo() {
// Gera o Word
}

E, por último, a Service ficaria assim:

class ImprimirService {
fun imprimir(arquivo: Arquivo) {
arquivo.gerarArquivo()
}
}

Lembre-se: quando uma classe herda algo de outra classe, podemos dizer que temos a relação de “É um”, na qual, no exemplo, o ArquivoPDF é um Arquivo e o ArquivoWord também é um Arquivo. Então, eles devem ter o mesmo comportamento base.

Vamos dar uma olhada em mais um exemplo bem simples:

open class User {
// Parametros do User
}

class UserPJ : User() {
// Parametros do User pj
}

class UserPF : User() {
// Parametros do User pf
}

Aqui, temos uma classe base de User, que é herdada por outras duas sub-classes: UserPJ e UserPF. E aqui, temos um método para fazer o login do usuário:

fun loginUser(user: User) {
// Efetua o login do usuário
}

Com base nos fundamentos do princípio, não importa se eu estou tentando fazer o login de um UserPJ, UserPF ou qualquer outra classe derivada de User. A função deve se comportar da mesma maneira para todos os casos.

I — Interface Segregation Principle (ou princípio segregação de interface)

O quarto princípio da metodologia nos diz que muitas interfaces de clientes específicas são melhores do que apenas uma para todos os propósitos.

Basicamente, ele quer dizer que uma classe deve ser forçada a implementar as interfaces que não vai utilizar. Abaixo, temos mais um exemplo de implementação que não segue esse conceito.

Vamos utilizar a seguinte interface para capturar clicks na tela:

interface Click {
fun onPress()
fun onLongPress()
}

E agora, temos a classe de botão que implementa esta interface, mas utiliza apenas um de seus métodos:

class Botao : Click {
override fun onPress() {
print("Execute click")
}

override fun onLongPress() {
// não implementado
}
}

Aqui, conseguimos ver que a classe de Botão é obrigada a implementar o método onLongPress, mesmo sem precisar utilizá-lo.

Neste caso, o ajuste é bem simples. Podemos dividir nossa interface Click em duas, cada uma contendo apenas o que faz sentido para ela:

interface SingleClick {
fun onPress()
}
interface LongClick {
fun onLongPress()
}

Desta maneira, a classe de Botão pode implementar apenas a interface que faz sentido para o seu uso:

class Botao : SingleClick {
override fun onPress() {
print("Execute click")
}
}

O processo de manter as interfaces simples e específicas torna mais fácil que as classes só necessitem saber os métodos que são de interesse para as suas funções.

D — Dependency inversion (ou princípio de inversão de dependência)

Por fim, o último princípio diz que devemos depender de abstrações, e não de objetos concretos.

Para explorarmos melhor esse pilar, vou definir alguns termos que facilitam o seu entendimento:

· Módulos de alto nível: classe que executa uma ação com uma ferramenta;

· Módulos de baixo nível: a ferramenta utilizada para executar a ação;

· Abstração: representa as interfaces (pontes) que conectam as duas classes;

· Detalhes: como a ferramenta funciona.

Este princípio diz que a Classe não deve ser fundida com a ferramenta que utiliza para realizar a ação. Ao invés disso, a Classe deve ser fundida com a interface que torna possível que ela se comunique com a ferramenta.

Ainda podemos dizer que nem a Classe e nem a Interface deve saber dos detalhes da ferramenta, mas a ferramenta deve sempre respeitar as especificações da interface, como se a interface fosse um contrato fixo entre as duas partes.

Para manter o padrão dos exemplos, vamos primeiro seguir com um caso que não segue o princípio:

Aqui, temos uma classe (nossa ferramenta) que está responsável por formatar um texto:

class FormatarCampo {
fun formatar() {
// Executa a formataçao
}
}

E aqui, temos outra classe (nossa classe de alto nível), que utiliza a ferramenta do FormatarCampo:

Perceba que a classe de CampoTexto tem uma referência direta a nossa ferramenta de formatação, criando uma instância dela e depois executando um método. Dessa forma, temos um acoplamento direto entre essas duas classes, o que pode causar problemas no futuro.

class CampoTexto {
fun enviarTexto() {
val formatarCampo = FormatarCampo()
formatarCampo.formatar()
}
}

Agora, vamos adicionar uma abstração entre a classe e a ferramenta para se adequar ao princípio. Para isso, vamos criar uma interface que contém a função de formatação e implementar essa interface em uma classe:

interface FormatarInterface {
fun format()
}
class FormatarTelefone : FormatarInterface {
override fun format() {
// Executa a formatação de telefone
}
}
class FormatarDocumento : FormatarInterface {
override fun format() {
// Executa a formatação de documento
}
}

Aqui, eu implementei a interface em duas classes distintas para mostrar a flexibilidade de implementações que temos quando seguimos esse princípio.

E agora, temos a classe de alto nível que recebe uma interface ao invés da ferramenta:

class CampoTexto(var formatInerface: FormatarInterface?) {

fun enviarTexto() {
formatInerface?.format()
}
}

Conseguiu perceber que, recebendo a interface como parâmetro, além de se adequar ao princípio, também se ganha com a flexibilidade de receber N ferramentas para serem executadas? Dessa maneira, podemos utilizar a ferramenta mais adequada para realizar a tarefa, mantendo sempre a interface como ponte.

Se você leu este artigo com atenção, provavelmente percebeu que muitos dos problemas que eu ilustrei aqui são resolvidos utilizando os próprios conceitos do SOLID, de forma quase cíclica.

Dito isso, reforço que o que faz com que o SOLID seja uma metodologia eficaz é sua implementação de forma completa. Não tente implementar apenas um dos conceitos por vez, pois eles conseguem se complementar de uma forma em que um leva ao uso outro, fazendo sua adoção total muito simples.

Se você curtiu a leitura, compartilhe este artigo com outras pessoas que podem se beneficiar desses aprendizados! Compartilhar conhecimento faz parte do processo de construção de bons códigos. E se você já aplica esses princípios no seu dia a dia, não se esqueça de compartilhar suas impressões nos comentários.

Referências:

· The S.O.L.I.D Principles in Pictures
· SOLID — Wikipedia
· Programação orientada a objetos
· SOLID Design Principles In Kotlin
· What is a Bad Code?

--

--