DEV Community

Cover image for Engenharia de Software #02 - SOLID 101
Pedro
Pedro

Posted on • Updated on

Engenharia de Software #02 - SOLID 101

Durante a vida útil de um software, a maior parte do custo do desenvolvimento de software é gasto na manutenção do software. Então, como desenvolvedores, queremos construir algo que não seja frágil, mas fácil de manter. E utilizar algumas regrinhas do SOLID pode nos ajudar a chegar nesse nível

Single Responsability Principle

public class SonoplastaRodrigoFaro {

    // Audio é um texto em representação aos bordões
    private List<String> audios = new ArrayList<>();
    private Integer count = 0;

    public void addAudio(String audio) {
        StringBuilder stringBuilder = new StringBuilder();

        String stringNova = stringBuilder
                .append(++count)
                .append(":")
                .append(audio)
                .toString();

        audios.add(stringNova);
    }

    public void removeAudio(String audio) {
        audios.removeIf(a -> a.equals(audio));
    }

    public List<String> getAudios() {
        return audios;
    }

    public Optional<String> getAudio(String audio) {
        return audios.stream()
                .filter(a -> a.equals(audio))
                .findFirst();
    }
}
}
Enter fullscreen mode Exit fullscreen mode

Temos aqui a classe Sonoplasta, que tem como atributos um contador de entradas e uma Lista de audios, e métodos fazem os CRUD desses respectivos áudios.

É uma classe com responsabilidades de Sonoplastia apenas, não há persistência e nem outras responsábilidades.

Código da persistência:

public class SonoplastaPersistence {

    public final static String PATH = "/home/pedro/Documentos/audios.txt";

    public void persist(List<String> audios) throws IOException {

        try(BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(PATH))) {

           String entradas = audios
                   .stream()
                   .map(item -> String.valueOf(item))
                   .collect(Collectors.joining("|"));

           bufferedWriter.write(entradas);

            System.out.println("Persistido com sucesso!");

        } catch (IOException e) {
            throw new IOException("Error on persist the archive");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Classe de Persistência que gera um arquivo em um diretório de sua preferencia com as anotações do seu Journal.

public class SRP {
    public static void main(String[] args) throws IOException {
        SonoplastaRodrigoFaro sonoplasta = new SonoplastaRodrigoFaro();

        sonoplasta.addAudio("CAVALOO");
        sonoplasta.addAudio("UUUIII");
        sonoplasta.addAudio("ELE GOXXTA");
        sonoplasta.addAudio("Que isso meu fio calma");

        sonoplasta.getAudios()
                .forEach(System.out::println);

        sonoplasta.getAudio("1:CAVALOO")
                .ifPresentOrElse(audio -> System.out.println("Audio: " + audio), () -> System.out.println("Audio não existe"));


        SonoplastaPersistence sonoplastaPersistence = new SonoplastaPersistence();

        sonoplastaPersistence.persist(sonoplasta.getAudios());
    }
}
Enter fullscreen mode Exit fullscreen mode

E por fim, outra classe que faz a chamada dessas duas classes.

Demonstração do funcionamento:

Image description

Arquivo persistido:

Image description

Open Closed Principle

Esse é um princípio que é bem discutido. Ele diz que uma classe DEVE estar fechada para IMPLEMENTAÇÃO porém ABERTA para EXTENSÃO. Isso quer dizer: Uma classe quando feita, não deve ser modificada e sim herdada, isso gera um certo desconforto, porque não podemos alterar nossas classes? O jeito para seguirmos bem esse principio é utilizando de Abstrações, sendo classes abstratas ou até mesmo interface. Mostrarei duas formas de utilizar esse principio.

Provavelmente, você ja criou uma calculadora na vida, e nessa calculadora devia ser algo tipo:

if (action == "+") {
  doSum();
} else if (action == "-") {
  doSubtraction();
}
Enter fullscreen mode Exit fullscreen mode

e daí vai. Isso fere o OCP, porque? se criarmos a classe de calculo com esses dois métodos, teriamos mais dois para implementar depois, que seriam a divisão e multiplicação, estariamos alterando nossa classe ja pronta. Como podemos resolver isso?

Primeiro, vamos criar uma interface chamada ICalculation:

public interface ICalculation {
     void perform();
}
Enter fullscreen mode Exit fullscreen mode

ela possui um método chamado perform que atuará para todas as operações básicas.

Agora criaremos uma classe para cada operação básica, implementando a interface.


public class Multiplication implements ICalculation{

    private double left;
    private double right;
    private double result;

    @Override
    public void perform() {
        result = left * right;
        System.out.println("Result of multiplication: " + result);
    }

    public Multiplication(double left, double right) {
        this.left = left;
        this.right = right;
        this.result = 0.0;
    }

    public double getLeft() {
        return left;
    }

    public void setLeft(double left) {
        this.left = left;
    }

    public double getRight() {
        return right;
    }

    public void setRight(double right) {
        this.right = right;
    }


}
Enter fullscreen mode Exit fullscreen mode

faça isso para cada operação básica, apenas alterando o perform(). Percebemos que ao fazer isso, nosso código está genérico e funcionará com base nas implementações de cada operação. Exemplo:

public class OCP {
    public static void main(String[] args) {
        ICalculation calculation = new Multiplication(2, 2);
        calculation.perform();
    }
}
Enter fullscreen mode Exit fullscreen mode

Podemos ver que não precisamos fazer várias estruturas de decisões para cada uma operação, basta passarmos uma instancia da implementação que queremos. Um exemplo disso acima está sendo passada a multiplicação entre dois números, se quisessemos uma soma, passariamos uma instancia de Soma e assim por diante.

Lembre-se, não leve esse principio ao pé da letra.

Specification Pattern funciona muito bem com OCP

Vamos utilizar outro exemplo pondo em prática OCP com um padrão chamado Specification.

obs: instalei o lombok, pois como faremos tudo na mesma classe devido ao tamanho do exemplo, para não ficar trocentas linhas na página estaremos utilizando as anotações do lombok para getters, setters e constructors.

public class FilterBySpecification {
    public static void main(String[] args) {
        var iphone = new Product(6.7, "iphone", 1800);
        var motorola = new Product(6.8, "motorola", 900);
        var iphoneX = new Product(6.9, "iphone", 3800);

        var listProducts = List.of(iphone, motorola, iphoneX);

        FilterProduct filterProduct = new FilterProduct();

        filterProduct.filterByName(listProducts, "iphone")
                .forEach(System.out::println);

    }

    static class FilterProduct {
        public List<Product> filterByName(List<Product> products, String name) {
            return products.stream()
                    .filter(product -> product.getName().equals(name))
                    .collect(Collectors.toList());
        }
    }

    @Data
    @AllArgsConstructor
    @ToString
    static class Product {
        public double size;
        public String name;
        public double price;

    }
}
Enter fullscreen mode Exit fullscreen mode

Logo acima, temos 3 classes simples, uma de produtos, uma para o método maine outra para a filtragem de produtos. podemos ver que ja filtramos produtos pelo nome, isso está certo, porém, se o seu chefe te pedir para filtrar por PREÇO também? depois por Tamanho? depois por Preço e Tamanho, e assim por diante? você adicionaria mais métodos dentro da classe ja pronta? um filterBySize? Bom, funcionaria porém fere o Open Closed Principle, desta forma, podemos implementar usando um padrão chamado Specification:

public class FilterBySpecification {
    interface Specification<T> {
        boolean isSatisfied(T item);
    }

    public static void main(String[] args) {
        var iphone = new Product(6.7, "iphone", 1800);
        var motorola = new Product(6.8, "motorola", 900);
        var iphoneX = new Product(6.9, "iphone", 3800);

        var listProducts = List.of(iphone, motorola, iphoneX);

        FilterItens filterProduct = new FilterItens();

        filterProduct.filterItem(listProducts, new NameSpecification("iphone"))
                .forEach(System.out::println);

    }

    interface Filter<T> {
        Stream<T> filterItem(List<T> itens, Specification<T> specification);
    }

    static class FilterItens implements Filter<Product> {

        @Override
        public Stream<Product> filterItem(List<Product> itens, Specification<Product> specification) {
            return itens.stream()
                    .filter(specification::isSatisfied);
        }
    }

    @AllArgsConstructor
    static class NameSpecification implements Specification<Product> {
        private String name;

        @Override
        public boolean isSatisfied(Product item) {
            return item.getName().equals(name);
        }
    }


    @Data
    @AllArgsConstructor
    @ToString
    static class Product {
        public double size;
        public String name;
        public double price;

    }
}
Enter fullscreen mode Exit fullscreen mode

O que fazemos aqui: Nós criamos uma interface genérica com o nome de Specification que possui um método chamado isSatisfied() que retorna um boolean, nós implementaremos esse método em nossos specifications que são, Size, Price e Name. Esse método servirá para filtrarmos nossos itens para cada specification. E criamos também uma classe chamada FilterItens que recebe uma lista de produtos e um Specification, podendo ser size, price, name, nós podemos fazer todos esses filtros usando somente UM método, respeitando o open closed principle. Se surgir uma nova necessidade de filtro, basta apenas criar uma nova Specification, como por exemplo: SizeSpecification e fazer o filtro por SizeSpecification ao invés de NameSpecification. isso fará com que não mexemos na nossa classe que tem a regra de negócio, de filtro, respeitando o principio. Exemplo do código em funcionamento:

Image description

Bi ou mais Specifications: Também é possível fazer com que seja comparados UMA ou MAIS Specifications por filtro, por exemplo, quero filtrar um produto por PREÇO e por NOME ao mesmo tempo. Exemplo:

Em nosso código, será adicionado mais uma interface e uma classe:

    interface BiFilter<T> {
        Stream<T> filterItens(List<T> itens, Specification<T> specification1, Specification<T> specification2);
    }

    static class BiFilterItens implements BiFilter<Product> {
        @Override
        public Stream<Product> filterItens(List<Product> itens, Specification<Product> specification1, Specification<Product> specification2) {
            return itens.stream()
                    .filter(item -> specification1.isSatisfied(item) && specification2.isSatisfied(item));
        }
    }
Enter fullscreen mode Exit fullscreen mode

Ele fará com que seja verificado as duas Specifications no mesmo filtro. Implemente isso no método main da seguinte forma:

    public static void main(String[] args) {
        var iphone = new Product(6.7, "iphone", 1800);
        var motorola = new Product(6.8, "motorola", 900);
        var iphoneX = new Product(6.9, "iphone", 3800);

        var listProducts = List.of(iphone, motorola, iphoneX);

        BiFilterItens filterProduct = new BiFilterItens();

        filterProduct.filterItens(listProducts, new NameSpecification("iphone"), new SizeSpecification(6.9))
                .forEach(System.out::println);

    }
Enter fullscreen mode Exit fullscreen mode

Perceba que criamos uma nova Specification chamada Size, onde será feito a lógica para o tamanho

    @AllArgsConstructor
    static class SizeSpecification implements Specification<Product> {
        private double size;

        @Override
        public boolean isSatisfied(Product item) {
            return item.getSize() == size;
        }
    }
Enter fullscreen mode Exit fullscreen mode

E estamos passando esse SizeSpecification como segundo parametro para o nosso filterItens.

Resultado do código:

Image description

Liskov Substitution principle

O princípio da Substituição de Liskov determina que uma classe derivada deve ser substituível por sua classe base.

Na prática, todas as classes filhas (que foram implementadas através de uma herança) devem manter os mesmos comportamentos da classe pai (Classe sendo herdada).

Vamos supor que temos uma classe Pessoa, com CPF, RG, Nome e uma classe Aluno com os mesmos atributos da classe Pessoa porém com Média e Faltas, percebeu que podemos fazer Aluno herdar Pessoa?

Devemos, no entanto, nos atentar a não violar este princípio, principalmente se:

  • Sobrescrever/implementar um método que não faz nada.
  • Retornar tipos e valores diferentes da classe pai.

Por exemplo, se na classe pai, cpf retorna String, na classe filho, cpf não pode retornar um Long. Pense como se os métodos da classe pai fosse um contrato de polimorfismo para a classe filha.

De fato, foi um exemplo bem razo, vamos tentar pensar em algo mais usual no dia a dia, como em midia sociais.

Vamos a seguinte classe pai:


public abstract class MediaSocial {
    void chamadaEmGrupo() {
        // Faça algo
    }

    void publicarPost() {
        // faça algo
    }

    void criarGrupos () {
        // faça algo
    }

    void chatComAmigo() {
        // faça algo
    }
}
Enter fullscreen mode Exit fullscreen mode

Vemos que temos uma classe chamada Media SOcial que tem todos as funcionalidades de uma rede social, como o facebook correto?

public class Facebook extends MediaSocial{

    void chamadaEmGrupo() {
        // Faça algo
    }

    void publicarPost() {
        // faça algo
    }

    void criarGrupos () {
        // faça algo
    }

    void chatComAmigo() {
        // faça algo
    }
}
Enter fullscreen mode Exit fullscreen mode

Podemos observar que o facebook implementa todos os métodos da classe MediaSocial, portanto não há violação do principio, pois um pode substituir o outro sem riscos de quebra de contrato.

Agora e se o Whatsapp herdasse Social Media?

public class Whatsapp extends MediaSocial{

        void chamadaEmGrupo() {
            // Faça algo
        }

        void publicarPost() {
            // faça algo
        }

        void criarGrupos () {
            // faça algo
        }

        void chatComAmigo() {
            // faça algo
        }
}
Enter fullscreen mode Exit fullscreen mode

Podemos notar que Whatsapp não tem Publicação de Postagens, então... boom! Portanto, a classe filha whatsapp não substitui a classe pai. Então não segue o principio do LSP

Mas como podemos resolver isso? Vou te mostrar na apresentação da letrinha I (Interface Segregation) do SOLID logo abaixo:

** Obs: Lembrando que, se possível evite fazer usos de Herança, use e abuse de interfaces, Assim como outros princípios do SOLID, o LSP pode se tornar um antipadrão — o que significa que, às vezes, a complexidade adicional não vale a compensação. Exceto em certos casos **

Interface Segregation

Você percebeu, que no exemplo acima, a classe abstrata MediaSocial está implementando trocentos métodos fazendo com que, Whatsapp, Instagram e outras redes sociais não possam implementá-la devida a quantidade de método presente nesse recurso?

Então vamos segregar as interfaces e deixar a parada mais bonita e usável.

Primeiro, vamos arrancar fora essa classe MediaSocial e criar uma interface chamada MediaSocial

interface MediaSocial {
    void chatComAmigo();
    void chamadaDeVoz();
}
Enter fullscreen mode Exit fullscreen mode

Essa interface, ela possui apenas uma responsabilidade, que se trata dos chats privados

Outra interface chamada MediaGrupo:

interface MediaGrupos {
    void chamadaEmGrupo();
    void criarGrupos ();
}
Enter fullscreen mode Exit fullscreen mode

Que possui responsabilidades de Grupos de pessoas.

e outra interface chamada MediaFeed:

interface MediaFeed {
    void publicarPost();
    void verPosts();
}
Enter fullscreen mode Exit fullscreen mode

Que é responsável pelo Feed de noticias das pessoas.

Agora, vamos ajustar as classes Facebook e Whatsapp:

public class Facebook implements MediaSocial, MediaGrupos, MediaFeed{

    @Override
    public void publicarPost() {

    }

    @Override
    public void verPosts() {

    }

    @Override
    public void chamadaEmGrupo() {

    }

    @Override
    public void criarGrupos() {

    }

    @Override
    public void chatComAmigo() {

    }

    @Override
    public void chamadaDeVoz() {

    }
}
Enter fullscreen mode Exit fullscreen mode

Como o Facebook possui todas as funções das interfaces que segregamos, ele implementará todas, Já o whatsapp:

public class Whatsapp implements MediaGrupos, MediaSocial{

        @Override
        public void chamadaEmGrupo() {

        }

        @Override
        public void criarGrupos() {

        }

        @Override
        public void chatComAmigo() {

        }

        @Override
        public void chamadaDeVoz() {

        }
}
Enter fullscreen mode Exit fullscreen mode

Só implementará DUAS interfaces, pois whatsapp não tem Feed de noticias.

Percebeu o ganho e abstração que tivemos implementando esse principio? Um dos melhores principios para sua vida!!

Dependency Inversion

Dependency Inversion basicamente diz que é pra você não depender de uma classe concreta e sim de uma abstração, Módulos de Alto nível não devem depender de módulos de baixo nível, ambos devem depender de abstração.

Bom, vamos criar uma classe que simula um pagamento, que teremos a opção de: Boleto Bancario, Cartões de crédito e débito.

public class Payment {
    private CartaoCredito cartaoCredito;
    private BoletoBancario boletoBancario;
    private DebitoCartao debitoCartao;

    public Payment(CartaoCredito cartaoCredito, BoletoBancario boletoBancario, DebitoCartao debitoCartao) {
        this.cartaoCredito = cartaoCredito;
        this.boletoBancario = boletoBancario;
        this.debitoCartao = debitoCartao;
    }

    public void doPayment() {
        // FAz o pagamento 
    }
}
Enter fullscreen mode Exit fullscreen mode

Do jeito que estamos fazendo, estamos usando injeção de dependência, isso é legal, mas fora isso, segundo o principio DIP, nossa classe está ERRADA DEMAIS, pois note, que a classe de alto nível Payment está dependendo de outras 3 classes concretas, CartaoCredito, BOletoBancario e DebitoCartao, lembra? não podemos depender de implementações e sim de abstrações...

Portanto, vamos criar uma interface chamada PaymentInterface

public interface PaymentInterface {
    void doSomething(double valor);
}
Enter fullscreen mode Exit fullscreen mode

E agora vamos fazer com que as 3 classes de meios de pagamento implementem essa interface.


public class DebitoCartao implements PaymentInterface{
    @Override
    public void doSomething(double valor) {
        System.out.println("Pagamento feito no débito, valor: " + valor);
    }
}
Enter fullscreen mode Exit fullscreen mode

e faremos essa implementação para todos os outros.

Agora na nossa classe Payment, vamos refatorá-la para atender ao DIP.


public class Payment {
    private PaymentInterface meioPagamento;

    public Payment(PaymentInterface meioPagamento) {
        this.meioPagamento = meioPagamento;
    }

    public void doPayment(double valor) {
        meioPagamento.doSomething(valor);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notou que agora estamos dependendo de uma Interface ao invés de uma implementação concreta? dessa forma conseguiremos fazer nossos pagamentos sem nos importar com a implementação de cada Classe de baixo nível (Boleto, Crédito e Débito)

vamos fazer o teste usando uma classe que instancia Payment e faz o fluxo:

public class Program {
    public static void main(String[] args) {
        PaymentInterface debito = new DebitoCartao();
        Payment pagamento = new Payment(debito);

        pagamento.doPayment(1200);
    }
}
Enter fullscreen mode Exit fullscreen mode

Image description

Agora no Crédito, usando a mesma lógica:

public class Program {
    public static void main(String[] args) {
        PaymentInterface credito = new CartaoCredito();
        Payment pagamento = new Payment(credito);

        pagamento.doPayment(1200);
    }
}
Enter fullscreen mode Exit fullscreen mode

Image description

Image description

Lembre-se, dependa sempre de abstrações(interface) e não de implementações (classes concretas)

Até a próxima :)

Top comments (0)