A qualidade do software é essencial para o sucesso de qualquer projeto de desenvolvimento. Um código bem estruturado, escrito com boas práticas e organizado de forma clara não apenas reduz a quantidade de erros, mas também facilita sua manutenção e evolução ao longo do tempo.

Mas como garantir um software de qualidade? 🤔

Não existe uma fórmula mágica, mas algumas abordagens podem fazer toda a diferença. Uma delas é aplicar os princípios SOLID, que ajudam a deixar o código mais organizado, flexível e sustentável.

Neste artigo, vamos explorar esses princípios na prática e entender como eles contribuem para um desenvolvimento mais eficiente.

Vamos lá?

Onde tudo começou 📅

O SOLID teve seus primeiros passos com Robert C Martin, também conhecido como “Uncle Bob”, onde ele escreveu um artigo em 1995 entitulado “The principles of OoD” (Algo como: Os Princípios do Design Orientado a Objetos).

Com o passar dos anos, Robert se empenhou em escrever e consolidar os princípios de seu artigo, de uma forma mais categórica. Em 2002 lançou seu livro “Agile Software Development, Principles, Patterns, and Practices” (Traduzindo: Desenvolvimento Ágil de Software, Princípios, Padrões e Práticas) onde reune diversos artigos sobre o tema.

Entretanto, a criação do termo “SOLID” não pertence a Robert, mas a Michael Feathers e aconteceu algum tempo depois.

Robert C Martin o “Uncle Bob”

Robert C Martin o “Uncle Bob”

Mas afinal, o que é SOLID? 🤔

O termo SOLID se trata de um acrônimo, e diz respeito a 5 princípios que são utilizados para facilitar o processo do desenvolvimento de softwares. Além de ajudar no desenvolvimento, ele torna mais prático a realização de manutenções e adição de novas funcionalidades.

Ele pode ser utilizado em qualquer linguagem de programação que utilize do paradigma da Orientação a Objetos.

Os 5 princípios são:

  • S - Single Reponsability Principle (Princípio da Responsabilidade Única)
  • O - Open-Closed Principle (Princípio Aberto-Fechado)
  • L - Liskov Substitution Principle (Princípio de Substituição de Liskov)
  • I - Interface Segregation Principle (Princípio da Segregação da Interface)
  • D - Dependency Inversion Principle (Princípio da Inversão de Dependência)

🟢 S - Single Responsibility Principle (SRP)

Princípio da Responsabilidade Única: “Uma classe deve ter apenas uma razão para mudar.”

Isso significa que cada classe ou módulo deve ter uma única responsabilidade e não misturar múltiplos propósitos. Além de tornar o código mais modular, também o torna mais fácil e prático de ser testado.

Imagine o cenário onde uma classe possui muitas responsabilidas, alterar um simples requisito pode levar a diversas alterações na classe por inteira. Por isso, as classes de uma aplicação devem ter responsabilidades únicas.

Este princípio se extende também a métodos/funções. Se um método/função possuir mais de uma responsabilidade, será difícil realizar testes e garantir que ele esteja funcionando como deveria.

Para entender melhor esse princípio vamos começar com o seguinte exemplo:

❌ Errado:

class Pedido {
  criarPedido() {
    /*...*/
  }

  enviarConfirmacao() {
    /*...*/  
  }

  gerarRelatorio() {
    /*...*/
  }
}

Nesse código, a classe Pedido está lidando com a criação de pedidos, com o envio de notificações e com geração de relatórios.

Pensando na Orientação a Objetos, um pedido deveria enviar notificações e gerar relatórios? Não! Um pedido deve conter funcionalidades que gerenciem os pedidos, não emails e relatórios.

A solução desse problema seria criar classes diferentes, cada uma com sua lógica e estrutura.

Correto:

class Pedido {
  criarPedido() {
    /*...*/
  }
}

class EnviarEmail {
  enviarEmailConfirmacao() {
    /*...*/  
  }
}

class Relatorio {
  gerarRelatorio() {
    /*...*/
  }
}

Nessa segunda versão, a classe Pedido terá somente o código que está relacionado as operações com pedidos. As outras operações foram separadas em classes diferentes, aplicando a separação de responsabilidades e mantendo cada classe responsável por uma parte diferente da aplicação.

Vantagens 🚀

Esses são alguns dos benefícios de se utilizar o Princípio da Responsabilidade Única:

  • Facilidade em realizar manutenções
  • Simplificação da legibilidade do código
  • Reusabilidade de classes e métodos/funções
  • Facilidade em realizar testes

🟠 OOpen-Closed Principle (OCP)

Princípio Aberto-Fechado: “O código deve estar aberto para extensão, mas fechado para modificação.”

Isso significa que devemos evitar modificar códigos existentes e, em vez disso, permitir extensões, usando dos conceitos de herança ou interfaces.

Se uma classe está aberta para modificações, se tornará cada vez mais complexo realizar a implementação de novos recuros. O melhor cenário é adptar o código para extender a classe, através de uma interface, dispensando a necessidade de alterá-la.

Ao aplicar o princípio Open-Closed, o código se assemelha ao mundo real, realizando de uma maneira bem sólida a Orientação a Objetos.

Para entender melhor esse princípio vamos começar com o seguinte exemplo:

Errado:

class Pagamento {
  efetuarPagamento(tipoPagamento: string) {
    if (tipoPagamento === "credito") {
      this.processarPagamentoCartaoDeCredito();
    } else if (tipoPagamento === "debito") {
      this.processarPagamentoCartaoDeDebito();
    }
}

  private processarPagamentoCartaoDeCredito() {
    console.log("Pagamento com cartão de crédito processado.");
  }

  private processarPagamentoCartaoDeDebito() {
    console.log("Pagamento com cartão de débito processado.");
  }
}

Analisando esse código, percebe-se que ele executaria normalmente e também iria cumprir com o seu objetivo.

Mas, e se fosse necessário adicionar uma nova forma de pagamento? Seguindo a estrutura, seria necessário modificar a classe para adicionar mais uma verificação - um if - e mais um método para o processamento do novo meio de pagamento.

Correto:

interface IMetodoPagamento {
  pagar(): void;
}

class PagamentoCartaoDeCredito implements IMetodoPagamento {
  pagar(): void {
    console.log("Pagamento com cartão de crédito processado.");
  }
}

class PagamentoCartaoDeDebito implements IMetodoPagamento {
  pagar(): void {
    console.log("Pagamento com cartão de débito processado.");
  }
}

class Pagamento {
  processarPagamento(metodoPagamento: IMetodoPagamento) {
    metodoPagamento.pagar();
  }
}

Nessa segunda versão, é utilizada uma abstração que permite que sejam adicionados novos meios de pagamento sem alterar a classe Pagamento. Para adicionar uma nova forma de pagamento, basta criar uma nova classe que implemente a interface IMetodoPagamento, assim mantendo a estrutura inical fechada para a modificações e aberta a extensões.

Vantagens 🚀

Esses são alguns dos benefícios de se utilizar o Princípio Aberto-Fechado:

  • Torna o projeto mais flexível
  • Possibilita a adição de novas funcionalidades de forma fácil.
  • Códigos mais legíveis
  • Diminui bugs de forma mais significativa.

🟡 LLiskov Substitution Principle (LSP)

Princípio da Substituição de Liskov: “Se S é uma subclasse de T, então os objetos do tipo T podem ser substituídos por objetos do tipo S sem alterar o comportamento do programa.”

Isso significa que uma subclasse pode substituir uma superclasse, sem interromper o funcionamento da aplicação.

Inicialmente, ele foi idealizado pela cientista da computção Barbara Liskov, entretanto Robert C Martin atribuiu uma definição mais simples a esse princípio: “Classes derivadas (ou classes-filhas) devem ser capazes de substituir suas classes-base (ou classes-mães)”.

Em outras palavras, Robert definiu que uma classe-filha deve ser capaz de realizar todas as operações de sua classe-mãe. Esse princípio reforça novamente o paradigma da Orientação a Objetos, utilizando o conceito de polimorfismo.

Para entender melhor esse princípio vamos começar com o seguinte exemplo:

Errado:

class Carro {
  abastecerComGasolina() {
    console.log("Abastecendo carro com gasolina...");
  }
}

class CarroEletrico extends Carro {
  abastecerComGasolina() {
    throw new Error("Carros elétricos não usam gasolina");
  }
}

// Uso:
const meuCarro: Carro = new CarroEletrico();
meuCarro.abastecerComGasolina(); // ❌ Erro inesperado!

Analisando é possível perceber que há algo de errado nesse código. Normalmente, carros elétricos não utilizam gasolina como seu combustível. Entretanto, a classe CarroEletrico é filha da classe Carro ela e herda todos os seus comportamentos.

Mesmo lançando a exceção no método abastecerComGasolina da classe filha (CarroEletrico), a estrutura continuaria sendo problemática, pois a classe CarroEletrico não teria os mesmos comportamentos de sua classe mãe (Carro).

Para estar adequado ao Princípio de Substituição de Liskov, deveria ser possível utilizar a classe CarroEletrico nos mesmos lugares onde estivesse a classe Carro, já que, pelo conceito de herança, um “carro elétrico” é um “carro”.

Correto:

abstract class Veiculo {
  abastecer(): void;
}

class CarroAGasolina extends Veiculo {
  abastecer() {
    console.log("Abastecendo carro com gasolina...");
  }
}

class CarroEletrico extends Veiculo {
  abastecer() {
    console.log("Carregando a bateria do carro elétrico...");
  }
}

// Uso:
const meuCarro: Veiculo = new CarroEletrico();
meuCarro.abastecer(); // ✅ Sem erro

Nessa segunda versão, além de modificar a estrutura inicial transformando a classe Carro na classe abstrata Veiculo, também é adicionado a classe CarroAGasolina. Com isso, esse código representa melhor o mundo real, não forçando uma classe a fazer algo que ela originalmente não faria.

Além disso, se houver a necessidade de utilizar uma instância de Veiculo, tanto uma instância de CarroAGasolina quanto uma intância de CarroEletrico podem ser utilizadas sem nenhum problema. Isso garante que uma classe filha execute todos os comportamentos de sua classe mãe.

Vantagens 🚀

Esses são alguns dos benefícios de se utilizar o Princípio da Substituição de Liskov:

  • Implementa uma modelagem mais fiel à realidade
  • Ajuda a reduzir erros inesperados na aplicação
  • Simplifica a manutenção do código

🔵 IInterface Segregation Principle (ISP)

Princípio de Segregação de Interfaces: “Nenhuma classe deve ser forçada a depender de métodos que não usa.”

Isso significa que devemos evitar a criação de interfaces grandes e genéricas, e, ao invés disso, realizar uma segregação a fim de torná-la menor e mais específica. Quando criamos uma interface, qualquer classe que deseje compartilhar os mesmos comportamentos precisa implementar todos os métodos que ela possui.

No paradigma da Orientação a Objetos, ao declaramos uma interface estamos estabelecendo um modelo de contrato, onde cada objeto terá seu comportamento definido com base no conjunto de métodos que essa interface expõe.

Para entender melhor esse princípio vamos começar com o seguinte exemplo:

Errado:

interface Dispositivo {
  imprimir(documento: string): void;
  escanear(documento: string): void;
  fax(documento: string): void;
}

class ImpressoraBasica implements Dispositivo {
  imprimir(documento: string): void {
    console.log(`Imprimindo: ${documento}`);
  }

// ❌ Métodos que não fazem sentido para uma impresoa básica:
  escanear(documento: string): void {
    throw new Error("Função de escaneamento não suportada.");
  }

  fax(documento: string): void {
    throw new Error("Função de fax não suportada.");
  }
}

Analisando esse código, é possível perceber que a classe ImpressoaBasica foi forçada a implementar métodos que não fazem sentido para ela.

Mesmo um objeto de ImpressoraBasica sendo um Dispositivo, não faz sentido que ela implemente métodos que não irá utilizar.

O cenário ideal seria onde a classe ImpressoraBasica implementasse uma interface que tivesse somente o(s) método(s) que ela fosse utilizar, nesse caso, apenas o método imprimir.

Correto:

interface Imprimir {
  imprimir(documento: string): void;
}

interface Escanear {
  escanear(documento: string): void;
}

interface Fax {
  fax(documento: string): void;
}

class ImpressoraBasica implements Imprimir {
  imprimir(documento: string): void {
    console.log(`Imprimindo: ${documento}`);
  }
}

class ImpressoraMultifuncional implements Imprimir, Escanear, Fax {
  imprimir(documento: string): void {
    console.log(`Imprimindo: ${documento}`);
  }

  escanear(documento: string): void {
    console.log(`Escaneando: ${documento}`);
  }

  fax(documento: string): void {
    console.log(`Enviado fax: ${documento}`);
  }
}

Nessa segunda versão, segregamos a interface Dispositivo em interfaces menores e mais específicas. Isso faz com que cada classe implemente somente as funcionalidades que serão utilizadas.

Agora a classe ImpressoraBasica implementa apenas a interface Imprimir, dispensando os métodos que antes não eram utilizados. Como a classe ImpressoraMultifuncional implementa as três interfaces, ela pode utilizar de todos os métodos.

Vantagens 🚀

Esses são alguns dos benefícios de se utilizar o Princípio de Segregação de Interfaces:

  • Aplicações mais coesas e mais flexíveis
  • Códigos mais fáceis de manter e estender

🟣 DDependency Inversion Principle (DIP)

Princípio da Inversão de Dependência: “Depende de abstrações, não de implementações.”

Isso significa que módulos de alto nível não devem depender diretamente de módulos de baixo nível. Para evitar essa dependência, deve existir uma camada de abstração entre ambos, buscando reduzir o acoplamento entre os componentes.

Na Orientação a Objetos, é comum que confundam a Inversão de Dependência com a Injeção de Dependência. Entretanto, mesmo sendo coisas diferentes, elas se relacionam entre si para tornar o código mais desacoplado.

NOTA: Não confunda a Inversão de Dependência com Injeção de Dependência. A Inversão de Dependência se trata de um princípio (conceito) enquanto a Injeção de Dependência se trata de um padrão de projeto (Design Pattern).

Para entender melhor esse princípio vamos começar com o seguinte exemplo:

Errado:

class NotificacaoViaSMS {
  enviar(mensagem: string): void {
    console.log(`Enviando SMS: ${mensagem} ...`);
  }
}

class GerenciadorDeNotificacao {
  private notificador = new NotificacaoViaSMS();

  notificar(mensagem: string): void {
    this.notificador.enviar(mensagem);
  }
}

A primeira impressão é que não existe nada errado com esse código. Entretanto, a classe GerenciadorDeNotificacao depende diretamente da implementação da classe NotificacaoViaSMS para o envio das mensagems.

Caso surja a necessidade de alterar o tipo de notificação, será necessário adicionar mais uma instância no GerenciadorDeNotificacao. Afinal, ela está diretamente acoplada à implementação concreta da classe NotificacaoViaSMS.

Para permitir a injeção de diferentes meios de notificação seria preciso utilizar de uma camada de abstração.

Correto:

interface Notificador {
  enviar(mensagem: string): void;
}

class NotificacaoViaSMS implements Notificador {
  enviar(mensagem: string): void {
    console.log(`Enviando SMS: ${mensagem} ...`);
  }
}

class NotificacaoViaEmail implements Notificador{
  enviar(mensagem: string): void {
    console.log(`Enviando Email: ${mensagem} ...`);
  }
}

class GerenciadorDeNotificacao {
  constructor(private notificador: Notificador){}

  notificar(mensagem: string): void {
    this.notificador.enviar(mensagem);
  }
}

// Uso:
const notificadorSMS = new NotificacaoViaSMS();
const notificadorEmail = new NotificacaoViaEmail();

const gerenciadorDeNotificacaoSMS = new GerenciadorDeNotificacao(notificadorSMS);
gerenciadorDeNotificacaoSMS.notificar("Seu pedido foi confirmado!");

const gerenciadorDeNotificacaoEmail = new GerenciadorDeNotificacao(notificadorEmail);
gerenciadorDeNotificacaoEmail.notificar("Seu pedido foi confirmado!");

Nessa segunda versão, a classe GerenciadorDeNotificacao passa a depender de uma abstração, a interface Notificador. Isso faz com que seja possível trabalhar com qualquer implementação que satisfaça a essa abstração, facilitando manutenções e extensões.

Com essas alterações, garantimos que a classe de alto nível (GerenciadorDeNotificacao) seja independente dos detalhes de implementação da classe de baixo nível (NotificacaoViaEmail ou NotificacaoViaSMS).

NOTA: No trecho “constructor(private notificador: Notificador){}” é utilizado da Injeção de Dependência, exemplificando existe diferença entre o princípio (Inversão de Dependência) e o padrão de projeto (Injeção de Dependência), mas que eles trabalham em conjunto.

Vantagens 🚀

Esses são alguns dos benefícios de se utilizar o Princípio de Inversão de Dependência:

  • Promove flexibilidade e extensibilidade das aplicações
  • Facilita a construção de testes de unidade
  • Ajuda a construir códigos mais robustos e duradouros

Conclusão 🎯

Ao aplicar esses princípios e boas práticas, você não apenas eleva a qualidade do seu código, mas também torna seu desenvolvimento mais claro. Programar vai além de simplesmente fazer a máquina executar comandos, trata-se de criar soluções bem estruturadas e que facilitem a colaboração.

No fim das contas, tudo isso se resume a uma frase famosa de Martin Fowler:

“Qualquer tolo consegue escrever código que um computador entenda. Bons programadores escrevem código que humanos possam entender.”

Neste artigo, exploramos o significado de SOLID e como seus princípios ajudam a tornar nossos projetos orientados a objetos mais organizados e eficientes.

Agora é hora de colocar esse conhecimento em prática e aplicar essas ideias no seu código para evoluir ainda mais como desenvolvedor!