Artigo original: What is SOLID? Principles for Better Software Design

Os princípios SOLID são um conjunto de diretrizes para escrever software de alta qualidade, manutenível e escalável.

Eles foram introduzidos por Robert C. Martin em seu artigo de 2000, "Design Principles and Design Patterns" (Princípios de padrões de design, em português – texto do link em inglês), para ajudar desenvolvedores a escrever software que seja fácil de entender, modificar e estender.

Esses conceitos foram posteriormente desenvolvidos por Michael Feathers, que nos apresentou a abreviação SOLID.

SOLID tem a ver com:

  • Single Responsibility Principle (Princípio da responsabilidade única - SRP)
  • Open-Closed Principle (Princípio aberto-fechado - OCP)
  • Liskov Substitution Principle (Princípio da substituição de Liskov - LSP)
  • Interface Segregation Principle (Princípio da segregação de interface - ISP)
  • Dependency Inversion Principle (Princípio da inversão de dependência - DIP)

Esses princípios fornecem uma maneira para os desenvolvedores organizarem seu código e criarem software flexível, fácil de alterar e testável. A aplicação dos princípios SOLID pode levar a um código mais modular, manutenível e extensível, além de poder facilitar a colaboração entre desenvolvedores em uma base de código.

Neste tutorial, exploraremos cada um dos princípios SOLID em detalhes. Explicaremos por que eles são importantes e forneceremos exemplos de como você pode aplicá-los na prática. Ao final deste tutorial, você deverá ter um bom entendimento dos princípios SOLID e de como aplicá-los aos seus projetos de desenvolvimento de software.

O que é o princípio da responsabilidade única?

O princípio da responsabilidade única (SRP) afirma que uma classe deve ter apenas um motivo para mudar, ou, em outras palavras, ela deve ter apenas uma responsabilidade. Isso significa que uma classe deve ter apenas um trabalho a fazer e deve fazê-lo bem.

Se uma classe tiver muitas responsabilidades, ela pode se tornar difícil de entender, manter e modificar. Mudanças em uma responsabilidade podem afetar inadvertidamente outra responsabilidade, levando a consequências indesejadas e bugs. Ao seguir o SRP, podemos criar código mais modular, fácil de entender e menos propenso a erros.

Vejamos um exemplo que viola o SRP:

class Marcador {
    String nome;
    String cor;
    int preco;

    public Marcador(String nome, String cor, int preco) {
        this.nome = nome;
        this.cor = cor;
        this.preco = preco;
    }
}

O código acima define uma classe Marcador simples com três variáveis de instância – nome, cor e preco.

class Fatura {
    private Marcador marcador;
    private int quantidade;

    public Fatura(Marcador marcador, int quantidade) {
        this.marcador = marcador;
        this.quantidade = quantidade;
    }

    public int calcularTotal() {
        return marcador.preco * this.quantidade;
    }

    public void imprimirFatura() {
        // implementação de impressão
    }

    public void salvarNoBancoDeDados() {
        // implementação de salvamento no banco de dados
    }
}

A classe Fatura acima viola o SRP porque tem várias responsabilidades – é responsável por calcular o valor total, imprimir a fatura e salvar a fatura no banco de dados. Como resultado, se a lógica de cálculo mudar, como a adição de impostos, o método calcularTotal() precisaria ser modificado. Do mesmo modo, se a implementação de impressão ou salvamento no banco de dados mudar em algum momento, a classe precisaria ser alterada.

Existem vários motivos para a classe ser modificada, o que pode levar ao aumento dos custos de manutenção e complexidade.

Veja como você pode modificar o código para seguir o SRP:

class Fatura {
    private Marcador marcador;
    private int quantidade;

    public Fatura(Marcador marcador, int quantidade) {
        this.marcador = marcador;
        this.quantidade = quantidade;
    }

    public int calcularTotal() {
        return marcador.preco * this.quantidade;
    }
}
class FaturaDao {
    private Fatura fatura;

    public FaturaDao(Fatura fatura) {
        this.fatura = fatura;
    }

    public void salvarNoBancoDeDados() {
        // implementação de salvamento no banco de dados
    }
}
class ImpressoraDeFatura {
    private Fatura fatura;

    public ImpressoraDeFatura(Fatura fatura) {
        this.fatura = fatura;
    }

    public void imprimirFatura() {
        // implementação de impressão
    }
}

Neste exemplo refatorado, dividimos as responsabilidades da classe Fatura em três classes separadas: Fatura, FaturaDao e ImpressoraDeFatura.

A classe Fatura é responsável apenas por calcular o valor total, e as responsabilidades de impressão e salvamento foram delegadas a classes separadas. Isso torna o código mais modular, fácil de entender e menos propenso a erros.

O que é o princípio aberto-fechado?

O princípio aberto-fechado (OCP) afirma que entidades de software (classes, módulos, funções e assim por diante) devem ser abertas para extensão, mas fechadas para modificação. Isso significa que o comportamento de uma entidade de software pode ser estendido sem modificar seu código-fonte.

O OCP é essencial porque promove a extensibilidade e a manutenibilidade do software. Ao permitir que entidades de software sejam estendidas sem modificação, os desenvolvedores podem adicionar novas funcionalidades sem o risco de quebrar o código existente. Isso resulta em código mais fácil de manter, estender e reutilizar.

Vejamos o exemplo anterior novamente.

class FaturaDao {
    private Fatura fatura;

    public FaturaDao(Fatura fatura) {
        this.fatura = fatura;
    }

    public void salvarNoBancoDeDados() {
        // implementação de salvamento no banco de dados
    }
}

A classe FaturaDao tem a responsabilidade única de salvar a fatura no banco de dados. Suponha, no entanto, que haja um novo requisito para salvar a fatura em um arquivo também. Uma maneira de implementar esse requisito seria modificar a classe FaturaDao existente adicionando um método salvarEmArquivo(). Isso, contudo, viola o princípio aberto-fechado porque modifica o código existente que já foi testado e está em produção.

Para seguir o OCP, uma solução melhor seria criar uma interface FaturaDao e implementá-la separadamente para salvamento em banco de dados e arquivo, conforme mostrado abaixo:

interface FaturaDao {
    public void salvar(Fatura fatura);
}

class FaturaDaoBancoDeDados implements FaturaDao {
    @Override
    public void salvar(Fatura fatura) {
        // implementação de salvamento no banco de dados
    }
}

class FaturaDaoArquivo implements FaturaDao {
    @Override
    public void salvar(Fatura fatura) {
        // implementação de salvamento em arquivo
    }
}

Desse modo, se houver um novo requisito para salvar a fatura em outro armazenamento de dados, você pode implementar uma nova implementação de FaturaDao sem modificar o código existente. Agora, a interface FaturaDao está aberta para extensão e fechada para modificação, o que segue o OCP.

O que é o princípio da substituição de Liskov?

O princípio da substituição de Liskov (LSP) afirma que qualquer instância de uma classe derivada deve ser substituível por uma instância de sua classe base sem afetar a correção do programa.

Em outras palavras, uma classe derivada deve se comportar como sua classe base em todos os contextos. Em termos mais simples, se a classe A é um subtipo da classe B, você deve ser capaz de substituir B por A sem quebrar o comportamento do seu programa.

A importância do LSP reside em sua capacidade de garantir que o comportamento de um programa permaneça consistente e previsível ao substituir objetos de classes diferentes. Violar o LSP pode levar a comportamento inesperado, bugs e problemas de manutenibilidade.

Vejamos um exemplo.

interface VeiculoDeDuasRodas {
    void ligarMotor();

    void acelerar();
}

No exemplo dado, a interface VeiculoDeDuasRodas tem dois métodos, ligarMotor() e acelerar(). Duas classes implementam essa interface, Motocicleta e Bicicleta.

class Motocicleta implements VeiculoDeDuasRodas {

    boolean motorLigado;
    int velocidade;

    @Override
    public void ligarMotor() {
        motorLigado = true;
    }

    @Override
    public void acelerar() {
        velocidade += 5;
    }
}

Motocicleta implementa corretamente o método ligarMotor(), pois define o booleano motorLigado como verdadeiro. Ele também implementa corretamente o método acelerar() aumentando a velocidade em 5.

class Bicicleta implements VeiculoDeDuasRodas {

    boolean motorLigado;
    int velocidade;

    @Override
    public void ligarMotor() {
        throw new AssertionError("Não há motor!");
    }

    @Override
    public void acelerar() {
        velocidade += 5;
    }
}

No entanto, a classe Bicicleta lança um AssertionError no método ligarMotor() porque não tem motor. Isso significa que uma instância de VeiculoDeDuasRodas não pode ser substituída por uma instância de Bicicleta sem quebrar o comportamento do programa.

Em outras palavras, se a classe Bicicleta for considerada um subtipo da interface VeiculoDeDuasRodas, de acordo com o LSP, qualquer instância de VeiculoDeDuasRodas deve ser substituível por uma instância de Bicicleta sem alterar a correção do programa.

Neste caso, porém, isso não é verdade, porque Bicicleta lança um AssertionError ao tentar ligar o motor. Portanto, o código viola o LSP.

O que é o princípio da segregação de interface?

O princípio da segregação de interface (ISP) se concentra no design de interfaces que são específicas para as necessidades de seus clientes. Ele afirma que nenhum cliente deve ser forçado a depender de métodos que não usa.

O princípio sugere que em vez de criar uma interface grande, que cubra todos os métodos possíveis, é melhor criar interfaces menores e mais focadas para casos de uso específicos. Essa abordagem resulta em interfaces mais coesas e menos acopladas.

Considere uma interface Veiculo como abaixo:

interface Veiculo {
    void ligarMotor();
    void desligarMotor();
    void dirigir();
    void voar();
}

E então você tem uma classe chamada Carro que implementa a interface Veiculo:

class Carro implements Veiculo {

    @Override
    public void ligarMotor() {
        // implementação
    }

    @Override
    public void desligarMotor() {
        // implementação
    }

    @Override
    public void dirigir() {
        // implementação
    }

    @Override
    public void voar() {
        throw new UnsupportedOperationException("Este veículo não pode voar.");
    }
}

Neste exemplo, a interface Veiculo tem muitos métodos. A classe Carro é forçada a implementar todos eles, embora não possa voar. Isso viola o ISP porque a interface Veiculo não é segregada adequadamente em interfaces menores com base na funcionalidade relacionada.

Vamos entender como você pode seguir o ISP aqui. Suponha que você refatore a interface Veiculo em interfaces menores e mais focadas:

interface Dirigivel {
    void ligarMotor();
    void desligarMotor();
    void dirigir();
}

interface Voador {
    void voar();
}

Agora, você pode ter uma classe chamada Carro, que implementa apenas a interface Dirigivel:

class Carro implements Dirigivel {

    @Override
    public void ligarMotor() {
        // implementação
    }

    @Override
    public void desligarMotor() {
        // implementação
    }

    @Override
    public void dirigir() {
        // implementação
    }
}

Graças à segregação de interface, você pode ter outra classe chamada Aviao, que implementa as interfaces Dirigivel e Voador:

class Aviao implements Dirigivel, Voador {

    @Override
    public void ligarMotor() {
        // implementação
    }

    @Override
    public void desligarMotor() {
        // implementação
    }

    @Override
    public void dirigir() {
        // implementação
    }

    @Override
    public void voar() {
        // implementação
    }
}

Neste exemplo, você segregou adequadamente a interface Veiculo em interfaces menores com base na funcionalidade relacionada. Isso adere ao ISP e torna seu código mais flexível e manutenível.

O que é o princípio da inversão de dependência?

O princípio da inversão de dependência (DIP) afirma que módulos de alto nível não devem depender de módulos de baixo nível, mas ambos devem depender de abstrações. Abstrações não devem depender de detalhes – detalhes devem depender de abstrações.

Esse princípio visa reduzir o acoplamento entre módulos, aumentar a modularidade e tornar o código mais fácil de manter, testar e estender.

Por exemplo, considere um cenário em que você tem uma classe que precisa usar uma instância de outra classe. Na abordagem tradicional, a primeira classe criaria diretamente uma instância da segunda classe, levando a um acoplamento forte entre elas. Isso torna difícil alterar a implementação da segunda classe ou testar a primeira classe independentemente.

Se, contudo, você aplicar o DIP, a primeira classe dependeria de uma abstração da segunda classe em vez da implementação. Isso tornaria possível alterar facilmente a implementação e testar a primeira classe independentemente.

Aqui está um exemplo que viola o DIP:

class CondicoesDoTempo {
    private String condicoesAtuais;
    private EnviadorDeEmail enviadorDeEmail;

    public CondicoesDoTempo() {
        this.enviadorDeEmail = new EnviadorDeEmail();
    }

    public void setCondicoesAtuais(String descricaoDoTempo) {
        this.condicoesAtuais = descricaoDoTempo;
        if (descricaoDoTempo == "chuvoso") {
            enviadorDeEmail.enviarEmail("Está chuvoso");
        }
    }
}

class EnviadorDeEmail {
    public void enviarEmail(String mensagem) {
        System.out.println("Email enviado: " + mensagem);
    }
}

Neste exemplo, a classe CondicoesDoTempo cria diretamente uma instância da classe EnviadorDeEmail, tornando-a fortemente acoplada à implementação. Isso torna difícil alterar a implementação da classe EnviadorDeEmail ou testar a classe CondicoesDoTempo independentemente.

Aqui está um exemplo de como aplicar o DIP ao código acima:

interface Notificador {
    public void alertarCondicoesDoTempo(String descricaoDoTempo);
}

class CondicoesDoTempo {
    private String condicoesAtuais;
    private Notificador notificador;

    public CondicoesDoTempo(Notificador notificador) {
        this.notificador = notificador;
    }

    public void setCondicoesAtuais(String descricaoDoTempo) {
        this.condicoesAtuais = descricaoDoTempo;
        if (descricaoDoTempo == "chuvoso") {
            notificador.alertarCondicoesDoTempo("Está chuvoso");
        }
    }
}

class EnviadorDeEmail implements Notificador {
    public void alertarCondicoesDoTempo(String descricaoDoTempo) {
        System.out.println("Email enviado: " + descricaoDoTempo);
    }
}

class SMS implements Notificador {
    public void alertarCondicoesDoTempo(String descricaoDoTempo) {
        System.out.println("SMS enviado: " + descricaoDoTempo);
    }
}

Neste exemplo, criamos uma interface Notificador que define o método alertarCondicoesDoTempo. A classe CondicoesDoTempo agora depende dessa interface em vez da classe EnviadorDeEmail, tornando possível alterar facilmente a implementação e testar a classe CondicoesDoTempo independentemente.

Também criamos duas implementações da interface Notificador, EnviadorDeEmail e SMS, para demonstrar como você pode alterar a implementação da classe CondicoesDoTempo sem afetar seu comportamento.

Conclusão

Neste artigo, você aprendeu sobre os princípios SOLID, que são uma parte muito importante dos princípios de design em geral.

Ao aplicar esses princípios em seus projetos de desenvolvimento de software, você pode criar código mais fácil de manter, estender e modificar, levando a um software mais robusto, flexível e reutilizável. Isso também levará a uma colaboração melhor entre os membros da equipe, à medida que o código se torna mais modular e fácil de trabalhar.

Para mais tutoriais como este, acompanhe o editorial do freeCodeCamp em português.