Simples, Objetivo, Lógico, Inspirador e Dinâmico

Eu sei que é um assunto ao qual já se tem muito material por aí, mas recentemente ajudei alguns calouros da minha faculdade a entender os princípios SOLID de uma forma simples. Meus feedbacks foram bons então eu decidi compartilhar com vocês.

O que diabos é SOLID?

Os princípios SOLID são um conjunto de cinco princípios, não necessáriamente regras, mas diretrizes que nos ajudam a escrever um código que seja mais fácil de manter, entender e expandir. São eles:

  • Single Responsibility Principle: Uma classe deve ter apenas uma razão para mudar.
  • Open/Closed Principle: Uma classe deve estar aberta para extensão, mas fechada para modificação.
  • Liskov Substitution Principle: Supertipos devem ser substituíveis por seus Subtipos.
  • Interface Segregation Principle: Uma classe nunca deve ser forçada a implementar uma interface que não utiliza.
  • Dependency Inversion Principle: Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações.

Single-Responsibility Principle

Esse princípio define que uma classe/método/interface de ter apenas uma responsabilidade, por exemplo:

public class Book {
    // Propriedades
    private String name;
    private String author;
    private String text;

    // Construtor

    /*
    * Métodos relacionados às propriedades
    */

    // Substituir uma palavra no texto
    public String replaceWordInText(String word, String replacementWord){
        return text.replaceAll(word, replacementWord);
    }

    // Verifica se uma palavra existe no texto
    public boolean isWordInText(String word){
        return text.contains(word);
    }

    /*
    * Método não relacionado ao livro, apenas o print
    */

    // Printa o texto no console
    public void printTextToConsole(){
        System.out.println(text);
    }

    // Getters & setters
}

A classe Book deve lidar apenas com as propriedades relacionadas ao livro, mas nesse caso ela também está fazendo o output do texto, ou seja, 2 responsabilidades. O método printTextToConsole deveria estar em outra classe, BookPrinter por exemplo:

public class BookPrinter {
    /*
    * Método não relacionado ao livro, apenas o print
    */

    // Printa o texto no console
    public void printTextToConsole(){
        System.out.println(text);
    }
}

O SRP é importante porque torna o código mais legível, sustentável e testável. Também ajuda a evitar o anti-pattern God Object.

Dicas: Tente descrever a principal responsabilidade de uma classe, método ou interface, se você não consegue descrevê-lo em uma única frase, ele provavelmente tem mais de uma responsabilidade.

Além disso, tente escrever o nome dos métodos descrevendo sua responsabilidade. Por exemplo, se você tem o método signUpAndLogin(), ele provavelmente viola o princípio SRP.

Open-Closed Principle

O Open-Closed Principle afirma que as entidades de software devem estar ABERTAS para extensão, mas FECHADAS para modificação. Isso significa que um componente deve ser facilmente extensível sem modificar o próprio componente. Por exemplo:

public class PaymentManager {
    public void pay(double amount) {
        if (validateCard()) {
            // Lógica de Pagamento
        }
    }

    private Boolean validateCard() {
        // Lógica de Validação
    }
}

Vamos supor que você implementou a classe PaymentManager para lidar com pagamentos de cartão de crédito, e funcionou perfeitamente. Em seguida você precisou aceitar também cartões de débido, mas como o comportamento era parcecido você apenas ajustou o código que já existia.

Agora, e se você precisar adicionar o método de pagamento Bitcoin? Você teria que modificar a classe PaymentManager, violando o OCP para considerar essa nova forma de pagamento:

public class PaymentManager {
    public void pay(double amount) {
        if(isCard()) {
            if (validateCard()) {
                // Lógica de Pagamento
            }
        } else if(isBitcoin()) {
            if (validateBitcoin()) {
                // Lógica de Pagamento
            }
        }
    }

    private Boolean validateCard() {
        // Lógica de Validação
    }

    private Boolean validateBitcoin() {
        // Lógica de Validação
    }
}

Então, como resolvemos esse problema? Com o nível correto de abstração:

Classe PaymentManager:

public class PaymentManager {
    public void pay(PaymentMethod paymentMethod, double amount) {
        if(paymentMethod.validate()) {
            paymentMethod.pay(amount);
            // Resto da rotina
        } else {
           // Lida com o erro de validação
        }
    }
}

Interface PaymentMethod:

public interface PaymentMethod {
    public void pay(double amount);
    public boolean validate();
}

Agora você pode adicionar qualquer método de pagamento sem modificar a classe PaymentManager, você só precisa criar uma nova classe que implementa a interface PaymentMethod.

Liskov Substitution Principle

O LSP (não confundir com Lisp) define que que objetos de uma superclasse devem ser substituíveis por objetos de sua subclasse sem afetar a funcionalidade do programa. Em outras palavras, uma classe filho deve substituir os métodos da sua classe pai de uma maneira que as rotinas não quebrem.

Por exemplo, considere a interface Bird:

public interface Bird {
    public void fly(); // Voar
    public void peck(); // Bicar
}

E você decide criar a classe Duck, que implementa Bird:

public class Duck implements Bird {
    @Override
    public void fly() {
        // Patos voam
    }

    @Override
    public void peck() {
        // Patos bicam
    }
}

Até aí tudo bem, mas agora você decide criar a classe Penguin:

public class Penguin implements Bird {
    @Override
    public void fly() {
        // Pinguins não voam! (Exceção)
        throw new UnsupportedOperationException();
    }

    @Override
    public void peck() {
        // Pinguins bicam
    }
}

A classe Penguin viola o Liskov Substitution Principle porque lança uma exceção quando o método fly() é chamado e esse não é o comportamento esperado da interface Bird.

Então, qual é a maneira correta de implementar a classe Penguin? Com o nível correto de abstração:

public interface Bird {
    public void peck(); // Nem todas as aves voam
}   

Se você tem classes que não implementam corretamente os métodos de uma interface, você provavelmente está violando o Liskov Substitution Principle.

Mas e agora? Por mais que os Pinguins não voem, existem aves que voam, e a interface Bird já não contempla elas.

O que acontece é que a interface Bird não foi reduzida, ela foi SEGRAGADA:

Interface Segregation Principle

O Interface Segregation Principle afirma que uma classe não deve ser forçada a implementar uma interface que não usa. Isso significa que não devemos ter uma única interface que contém métodos que não são usados pela classe, em vez disso, devemos ter várias interfaces, cada uma contendo métodos que são usados pela classe.

Voltando ao exemplo anterior, percebemos que a classe Penguin estava sendo obrigada a implementar um método que não utilizava. Lembra que a interface Bird não foi reduzida, ela foi segragada, então nós temos:

public interface Bird {
    public void peck(); // Aves bicam
}   

Nem todos os pássaros podem voar, então o método fly() não deve fazer parte da interface Bird, ele deve fazer parte de uma interface mais específica, como FlyingBird:

public interface FlyingBird extends Bird {
    public void fly(); // Nem todas as aves voam
}

Agora, a classe Duck deve implementar a interface FlyingBird:

public class Duck implements FlyingBird {
    @Override
    public void fly() {
        // Patos voam
    }

    @Override
    public void peck() {
        // Patos bicam
    }
}

Agora a classe Penguin pode permanecer implementando a interface Bird:

public class Penguin implements Bird {
    @Override
    public void peck() {
        // Pinguins bicam
    }
}'

Agora, ambas as classes Duck e Penguin estão implementando apenas os métodos que usam, e estamos seguindo o Interface Segregation Principle corretamente.

Dependency Inversion Principle

O Dependency Inversion Principle diz 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. Além disso, as abstrações não devem depender de detalhes, mas sim os detalhes devem depender de abstrações. Por exemplo:

public class ClassA {
    ClassB objectB;

    public ClassA(ClassB objectB) {
        this.objectB = objectB;
    }

    public void foo() {
        // Rotina aqui
        // E então chama algum método do objectB
        objectB.doSomething();
    }
}

No exemplo acima, ClassA é um módulo de alto nível e ClassB é um módulo de baixo nível, sendo que ClassA é diretamente dependente de ClassB.

Mas, o DIP nos diz para inverter a dependência usando uma abstração, e podemos fazer isso criando uma interface:

public interface InterfaceB {
    public void doSomething();
}

Fazendo isso podemos alterar a ClassA para usar a InterfaceB em vez da ClassB:

public class ClassA {
    InterfaceB objectB;

    public ClassA(InterfaceB objectB) {
        this.objectB = objectB;
    }

    public void foo() {
        // Rotina aqui
        // E então chama algum método do objectB
        objectB.doSomething();
    }
}

Dessa forma nós invertemos a dependência na ClassA, fazendo com que ela dependa da InterfaceB em vez da ClassB. Essa é a essência do Dependency Inversion Principle.

Why is the DIP important?

Ao desenvolver aplicações, é comum organizarmos nossa lógica em vários módulos, no entanto, isso pode levar a uma base de código com dependências.

Uma das motivações por trás do Dependency Inversion Principle é nos prevenir de depender de módulos que mudam frequentemente. Classes comuns costumam passar por modificações frequentes, enquanto abstrações e interfaces estão sujeitas a mudanças menos frequentes. Essa abordagem simplifica tarefas como correção de bugs, recompilação de código ou fusão de diferentes branches.

No entanto, a importância do DIP vai além disso, ele desempenha um papel fundamental na obtenção de sistemas pouco acoplados e de fácil manutenção, complementando conceitos como Polymorphism e Dependency injection.

Considerações Finais

É interessante como os princípios SOLID se complementam, e chegam quase a ser um consequência do outro.

Mas é sempre bom lembrar que na tecnologia não existe bala de prata, todo conhecimento obtido deve ser ponderado antes de ser aplicado em projetos e afins. Não seja um dev emocionado!

Espero que esse artigo tenha sido proveitoso pra você, muito obrigado pro ler!

Links Pertinentes